diff --git a/.env.example b/.env.example index b7f3b008f..924146613 100644 --- a/.env.example +++ b/.env.example @@ -417,9 +417,9 @@ IMAGE_TOOLS_DEBUG=false # Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed. # Install with: pip install faster-whisper # Model downloads automatically on first use (~150 MB for "base"). -# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above. -# Provider priority: local > groq > openai -# Configure in config.yaml: stt.provider: local | groq | openai +# To use cloud providers instead, set GROQ_API_KEY, VOICE_TOOLS_OPENAI_KEY, or ELEVENLABS_API_KEY above. +# Provider priority: local > groq > openai > mistral > xai > elevenlabs +# Configure in config.yaml: stt.provider: local | groq | openai | mistral | xai | elevenlabs # ============================================================================= # STT ADVANCED OVERRIDES (optional) @@ -427,10 +427,12 @@ IMAGE_TOOLS_DEBUG=false # Override default STT models per provider (normally set via stt.model in config.yaml) # STT_GROQ_MODEL=whisper-large-v3-turbo # STT_OPENAI_MODEL=whisper-1 +# STT_ELEVENLABS_MODEL=scribe_v2 # Override STT provider endpoints (for proxies or self-hosted instances) # GROQ_BASE_URL=https://api.groq.com/openai/v1 # STT_OPENAI_BASE_URL=https://api.openai.com/v1 +# ELEVENLABS_STT_BASE_URL=https://api.elevenlabs.io/v1 # ============================================================================= # MICROSOFT TEAMS INTEGRATION diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml index 68fab8605..36b86f18c 100644 --- a/.github/workflows/nix-lockfile-fix.yml +++ b/.github/workflows/nix-lockfile-fix.yml @@ -6,8 +6,8 @@ on: paths: - 'ui-tui/package-lock.json' - 'ui-tui/package.json' - - 'web/package-lock.json' - - 'web/package.json' + - 'apps/dashboard/package-lock.json' + - 'apps/dashboard/package.json' workflow_dispatch: inputs: pr_number: @@ -28,7 +28,7 @@ concurrency: jobs: # ── Auto-fix on main ─────────────────────────────────────────────── # Fires when a push to main touches package.json or package-lock.json - # in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash + # in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash # update commit directly to main so Nix builds never stay broken. # # Safety invariants: @@ -110,7 +110,7 @@ jobs: # run recompute from the correct package-lock state. pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \ 'ui-tui/package-lock.json' 'ui-tui/package.json' \ - 'web/package-lock.json' 'web/package.json' || true)" + 'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)" if [ -n "$pkg_changed" ]; then echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute" exit 0 diff --git a/.gitignore b/.gitignore index 80984656b..ee1cb15f4 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,10 @@ environments/benchmarks/evals/ # Web UI build output hermes_cli/web_dist/ +apps/desktop/build/ +apps/desktop/dist/ +apps/desktop/release/ +apps/desktop/*.tsbuildinfo # Web UI assets — synced from @nous-research/ui at build time via # `npm run sync-assets` (see web/package.json). @@ -85,6 +89,16 @@ website/static/api/skills-index.json website/static/api/skills.json website/static/api/skills-meta.json models-dev-upstream/ + +# Local editor / agent tooling (machine-specific; keep in global config, not the repo) +.codex/ +.cursor/ +.gemini/ +.zed/ +.mcp.json +opencode.json +config/mcporter.json + hermes_cli/tui_dist/* hermes_cli/scripts/ docs/superpowers/* diff --git a/AGENTS.md b/AGENTS.md index dd45310ca..6c0036efd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ Instructions for AI coding assistants and developers working on the hermes-agent codebase. +**Never give up on the right solution.** + ## Development Environment ```bash @@ -66,6 +68,29 @@ hermes-agent/ `gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`. Browse with `hermes logs [--follow] [--level ...] [--session ...]`. +## TypeScript Style + +Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages. + +- Prefer small nanostores over component state when state is shared, reused, or read by distant UI. +- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`. +- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`. +- Do not pass state through three components when the leaf can subscribe to the atom. +- Keep persistence beside the atom that owns it. +- Keep route roots thin. They compose routes and shell; they should not become controllers. +- No monolithic hooks. A hook should own one narrow job. +- Prefer colocated action modules over hidden god hooks. +- If a callback is pure side effect, use the terse void form: + `onState={st => void setGatewayState(st)}`. +- Async UI handlers should make intent explicit: + `onClick={() => void save()}`. +- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props. +- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps`, `Omit<...>`, `Pick<...>`. +- Table-driven beats condition ladders when mapping ids, routes, or views. +- `src/app` owns routes, pages, and page-specific components. +- `src/store` owns shared atoms. +- `src/lib` owns shared pure helpers. + ## File Dependency Chain ``` diff --git a/README.md b/README.md index 7f0ce7f26..2f42c789b 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ Run this in PowerShell: iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) ``` -The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. +The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. -If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git. +If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git. > **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies. > @@ -104,17 +104,17 @@ You can still bring your own keys per-tool whenever you want — the gateway is Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces. -| Action | CLI | Messaging platforms | -|---------|-----|---------------------| -| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message | -| Start fresh conversation | `/new` or `/reset` | `/new` or `/reset` | -| Change model | `/model [provider:model]` | `/model [provider:model]` | -| Set a personality | `/personality [name]` | `/personality [name]` | -| Retry or undo the last turn | `/retry`, `/undo` | `/retry`, `/undo` | -| Compress context / check usage | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` | -| Browse skills | `/skills` or `/` | `/` | -| Interrupt current work | `Ctrl+C` or send a new message | `/stop` or send a new message | -| Platform-specific status | `/platforms` | `/status`, `/sethome` | +| Action | CLI | Messaging platforms | +| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- | +| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message | +| Start fresh conversation | `/new` or `/reset` | `/new` or `/reset` | +| Change model | `/model [provider:model]` | `/model [provider:model]` | +| Set a personality | `/personality [name]` | `/personality [name]` | +| Retry or undo the last turn | `/retry`, `/undo` | `/retry`, `/undo` | +| Compress context / check usage | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` | +| Browse skills | `/skills` or `/` | `/` | +| Interrupt current work | `Ctrl+C` or send a new message | `/stop` or send a new message | +| Platform-specific status | `/platforms` | `/status`, `/sethome` | For the full command lists, see the [CLI guide](https://hermes-agent.nousresearch.com/docs/user-guide/cli) and the [Messaging Gateway guide](https://hermes-agent.nousresearch.com/docs/user-guide/messaging). @@ -124,23 +124,23 @@ For the full command lists, see the [CLI guide](https://hermes-agent.nousresearc All documentation lives at **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**: -| Section | What's Covered | -|---------|---------------| -| [Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Install → setup → first conversation in 2 minutes | -| [CLI Usage](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Commands, keybindings, personalities, sessions | -| [Configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Config file, providers, models, all options | -| [Messaging Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant | -| [Security](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Command approval, DM pairing, container isolation | -| [Tools & Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ tools, toolset system, terminal backends | -| [Skills System](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Procedural memory, Skills Hub, creating skills | -| [Memory](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Persistent memory, user profiles, best practices | -| [MCP Integration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Connect any MCP server for extended capabilities | -| [Cron Scheduling](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Scheduled tasks with platform delivery | -| [Context Files](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Project context that shapes every conversation | -| [Architecture](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Project structure, agent loop, key classes | -| [Contributing](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Development setup, PR process, code style | -| [CLI Reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | All commands and flags | -| [Environment Variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Complete env var reference | +| Section | What's Covered | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| [Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Install → setup → first conversation in 2 minutes | +| [CLI Usage](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Commands, keybindings, personalities, sessions | +| [Configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Config file, providers, models, all options | +| [Messaging Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant | +| [Security](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Command approval, DM pairing, container isolation | +| [Tools & Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ tools, toolset system, terminal backends | +| [Skills System](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Procedural memory, Skills Hub, creating skills | +| [Memory](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Persistent memory, user profiles, best practices | +| [MCP Integration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Connect any MCP server for extended capabilities | +| [Cron Scheduling](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Scheduled tasks with platform delivery | +| [Context Files](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Project context that shapes every conversation | +| [Architecture](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Project structure, agent loop, key classes | +| [Contributing](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Development setup, PR process, code style | +| [CLI Reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | All commands and flags | +| [Environment Variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Complete env var reference | --- @@ -160,6 +160,7 @@ hermes claw migrate --overwrite # Overwrite existing conflicts ``` What gets imported: + - **SOUL.md** — persona file - **Memories** — MEMORY.md and USER.md entries - **Skills** — user-created skills → `~/.hermes/skills/openclaw-imports/` diff --git a/RELEASE_v0.14.0.md b/RELEASE_v0.14.0.md index 30ab4189a..38d40db8c 100644 --- a/RELEASE_v0.14.0.md +++ b/RELEASE_v0.14.0.md @@ -3,75 +3,73 @@ **Release Date:** May 16, 2026 **Since v0.13.0:** 808 commits · 633 merged PRs · 1393 files changed · 165,061 insertions · 545 issues closed (12 P0, 50 P1) · 215 community contributors (including co-authors) -> The Foundation Release — Hermes installs and runs anywhere, ships with the things you actually want to use, and stops shipping the things you don't. xAI Grok lands as a SuperGrok OAuth provider with grok-4.3 bumped to a 1M context window. A new OpenAI-compatible local proxy turns any OAuth-authed Hermes provider — Claude Pro, ChatGPT Pro, SuperGrok — into an endpoint that Codex / Aider / Cline / Continue can hit. `x_search` lands as a first-class X (Twitter) search tool with OAuth-or-API-key auth. The Microsoft Teams stack is wired end-to-end (Graph auth + webhook listener + pipeline runtime + outbound delivery). A debloating wave makes installs dramatically lighter — heavyweight backends now lazy-install on first use, the `[all]` extras drop everything covered by lazy-deps, and a tiered install falls back when a wheel rejects on your platform. `pip install hermes-agent` works from PyPI. The cold-start wave shaves ~19 seconds off `hermes` launch. Browser CDP calls are 180x faster. Two new messaging platforms (LINE + SimpleX Chat) bring the total to 22. Cross-session 1-hour Claude prompt caching, `/handoff` that actually transfers sessions live, native button UI for `clarify` on Telegram and Discord, Discord channel history backfill, LSP semantic diagnostics on every write, a unified pluggable `video_generate`, a `computer_use` cua-driver backend that finally works with non-Anthropic providers, clickable URLs in any terminal, Zed ACP Registry integration via `uvx`, native Windows beta, 9 new optional skills, OpenRouter Pareto Code router, huggingface/skills as a trusted default tap. 12 P0 + 50 P1 closures. +> The Foundation Release — Hermes Agent installs and runs anywhere now. Native Windows ships in early beta with a full PowerShell installer story, a `pip install hermes-agent` wheel lands on PyPI, lazy-deps reshape what `pip install hermes-agent` actually pulls down, the supply-chain checker scans every install/upgrade for unsafe versions, and a new OpenAI-compatible local proxy lets Codex / Aider / Cline talk to OAuth-only providers (Claude Pro, ChatGPT Pro, SuperGrok). The cold-start wave shaves ~19 seconds off `hermes` launch, browser-tool CDP calls run 180x faster, and `hermes tools` All-Platforms drops from 14s to under 1.5s. Two new messaging platforms (LINE and SimpleX Chat) and a Microsoft Graph foundation (Teams pipeline + webhook adapter) land alongside `/handoff` that finally transfers sessions live, `vision_analyze` passing pixels through to vision-capable models, `x_search` as a first-class tool, LSP semantic diagnostics on every `write_file` / `patch`, a unified pluggable `video_generate`, a `computer_use` cua-driver backend, cross-session 1-hour Claude prompt caching, a per-turn file-mutation verifier, plus 9 new optional skills. 50+ P1 closures, 12 P0 closures. --- ## ✨ Highlights -- **xAI Grok via SuperGrok OAuth — and grok-4.3 jumps to a 1M context window** — If you pay for SuperGrok, you can now use Grok inside Hermes by signing in with your xAI account — no API key, no separate billing. The wire-through also bumps grok-4.3 to a 1M token context window, so you can drop whole codebases or research corpora into a single prompt. Includes proper handling for entitlement errors and an SSH-to-tunnel docs page for when you're SSH'd into a remote box and need to complete the OAuth flow. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534), [#26664](https://github.com/NousResearch/hermes-agent/pull/26664), [#26644](https://github.com/NousResearch/hermes-agent/pull/26644), [#26592](https://github.com/NousResearch/hermes-agent/pull/26592)) +- **Native Windows support (early beta)** — full PowerShell installer, native subprocess/PTY paths, taskkill-based process management, MinGit auto-install, Microsoft Store python stub detection, foreground Ctrl+C preservation, taskkill+ps2 fallback, npm prefix handling, and ~40 follow-up Windows-only fixes across CLI / gateway / TUI / curator / tools. Hermes finally runs natively on `cmd.exe` and PowerShell, no WSL required. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561), [#22130](https://github.com/NousResearch/hermes-agent/pull/22130), [#22752](https://github.com/NousResearch/hermes-agent/pull/22752), [#26618](https://github.com/NousResearch/hermes-agent/pull/26618), and many more) -- **OpenAI-compatible local proxy for OAuth providers** — Run `hermes proxy` and you get a `http://localhost:port` endpoint that speaks the OpenAI API but is backed by whichever OAuth provider you're signed into — Claude Pro, ChatGPT Pro, SuperGrok. Now any tool that expects an OpenAI-compatible endpoint (Codex CLI, Aider, Cline, Continue, your custom scripts) just works with your existing subscription, no API key required. One subscription, every tool. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969)) +- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. One command, no clone, no git, no shell installer. Wheel includes the Ink TUI bundle and shell launcher. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593)) -- **`x_search` — first-class X (Twitter) search tool** — The agent can now search X directly without installing a skill or wiring up a custom integration. Search the timeline, find threads, surface specific posts — straight from the chat. Auth with either your X OAuth login or an API key, whichever you have. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763)) +- **Cold-start performance wave — ~19s off `hermes` launch** — skills cache, lazy Feishu import, no Nous HTTP at startup, plus PEP-562 lazy adapter imports (QQ, Yuanbao, Teams, Google Chat), deferred `fal_client` / `google-cloud` / `httpx` loads, models.dev disk-cache-first lookup, parallel doctor API checks, eager-skip plugin discovery on built-in subcommands, `hermes tools` All-Platforms drops from 14s to <1.5s, welcome banner skipped on `chat -q`. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341)) -- **Microsoft Teams — end-to-end** — Hermes can now read messages from Teams and post back. The full Microsoft Graph stack lands together: auth + client foundation, a webhook listener that receives Teams events, a pipeline plugin runtime, and outbound delivery. Wire up the bot once, then chat to your agent from any Teams channel, DM, or group. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024)) +- **180x faster `browser_console` evaluations** — routed through the supervisor's persistent CDP WebSocket instead of spawning a fresh DevTools session per call. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226)) -- **Debloating wave — lighter installs, less you don't use** — A clean `pip install hermes-agent` used to pull down everything: every messaging adapter SDK, every image-gen SDK, every voice/TTS provider, whether you used them or not. Now those heavy backends (Slack / Matrix / Feishu / DingTalk adapters, hindsight client, codex app-server, Pixverse / Camofox / image-gen SDKs, voice/TTS providers) install automatically the first time you actually use them. The `[all]` extras drop everything covered by lazy-deps, the installer falls back through tiers when a wheel doesn't fit your platform, and a supply-chain advisory checker scans every install for unsafe versions. Faster installs, smaller disk footprint, fewer transitive vulnerabilities. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220), [#24515](https://github.com/NousResearch/hermes-agent/pull/24515), [#25014](https://github.com/NousResearch/hermes-agent/pull/25014), [#25038](https://github.com/NousResearch/hermes-agent/pull/25038), [#25766](https://github.com/NousResearch/hermes-agent/pull/25766), [#21818](https://github.com/NousResearch/hermes-agent/pull/21818)) +- **Supply-chain advisory checker + lazy-deps framework + tiered install fallback** — every `pip install` / `hermes update` scans dependencies against an advisory list, lazy-deps replace heavy import-time loads with first-use installs, and the installer falls back through extras tiers when a wheel rejects on the target platform. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220)) -- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. No more cloning the repo or running shell installers — one pip command and you're running. The wheel ships with the Ink TUI bundle and the shell launcher, so the full experience comes out of the box. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593), [#26148](https://github.com/NousResearch/hermes-agent/pull/26148)) +- **OpenAI-compatible local proxy** — `hermes proxy` exposes any OAuth-authed provider (Claude Pro, ChatGPT Pro, SuperGrok) as an OpenAI-compatible endpoint that Codex / Aider / Cline / VS Code Continue can hit. Your subscription, your tools. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969)) -- **Cross-session 1h Claude prompt cache** — When you use Claude through Anthropic, OpenRouter, or Nous Portal, the prompt prefix (system prompt, skills, memory) now caches for an hour across sessions. Start a `/new` session and the first response comes back faster and cheaper because the cache is still warm from your last session. Background memory review hits the cache too, so it's not paying full price every turn. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828), [#25434](https://github.com/NousResearch/hermes-agent/pull/25434), [#24778](https://github.com/NousResearch/hermes-agent/pull/24778)) +- **Cross-session 1-hour Claude prompt cache** — Anthropic / OpenRouter / Nous Portal now share a 1h prefix cache across sessions for Claude models. Fast resume, fast `/new`, lower cost on repeat work. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828)) -- **180x faster `browser_console` evaluations** — When the agent uses the browser tool to inspect a page or run JavaScript, those calls now share one persistent connection to Chrome instead of spinning up a new DevTools session every time. The difference is huge: things that used to take a couple of seconds per call return in milliseconds. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226)) +- **Two new messaging platforms — LINE + SimpleX Chat** — LINE Messaging API lands as a first-class platform, SimpleX Chat salvages #2558 onto the modern adapter spec. Hermes is now on 22 platforms. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232)) -- **Cold-start performance wave — ~19 seconds off `hermes` launch** — Running `hermes` used to make you wait through a chunk of import overhead and network calls before you saw a prompt. Now the launch path is mostly deferred: heavy adapters only load when you use them, model catalogs come from disk cache first, doctor checks run in parallel, and `chat -q` skips the welcome banner entirely. The `hermes tools` All-Platforms screen alone dropped from 14 seconds to under 1.5 seconds. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341)) +- **Microsoft Graph foundation — Teams pipeline + webhook adapter** — `msgraph` auth/client foundation, webhook listener platform, Teams pipeline plugin runtime, and Teams outbound delivery via the existing adapter — Hermes can now read and post to Teams. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024)) -- **Two new messaging platforms — LINE + SimpleX Chat** — LINE is huge in Japan, Korea, and Taiwan, and now Hermes runs natively on the LINE Messaging API. SimpleX Chat is the privacy-focused decentralized messenger with no user IDs — also wired up as a first-class platform. That brings Hermes to 22 messaging platforms total, so wherever you and your team chat, the agent can be there. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232)) +- **`/handoff` actually transfers the session live** — the agent's active session moves to a different model / persona / profile mid-conversation, with messages, tool history, and context preserved. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395)) -- **`/handoff` actually transfers the session live** — Switching models or personalities mid-conversation used to mean losing context or starting over. Now `/handoff` moves your active session — every message, every tool call, every piece of context — to the target model, persona, or profile, live, without dropping anything. Mid-debugging hand off from a fast model to a deep-reasoning one, or pass a session between profiles for different parts of a task. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395)) +- **`x_search` — first-class X (Twitter) search tool** — gated tool with OAuth-or-API-key auth, no skill needed to query the timeline. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763)) -- **Native button UI for `clarify` on Telegram and Discord** — When the agent uses the `clarify` tool to ask you a multiple-choice question, it now shows real platform-native buttons on Telegram and Discord instead of asking you to type back the option number. Tap the button, the agent gets your answer. Especially nice on mobile. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485)) +- **`vision_analyze` returns pixels to vision-capable models** — when the active model can see, `vision_analyze` now hands the image straight through instead of falling back to a text description. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955)) -- **Discord channel history backfill (default on)** — When Hermes joins a Discord channel or thread for the first time, it now reads the recent message history so it knows what's been said before it responds. No more "what are we talking about?" — the agent has the context that's already on screen for everyone else. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984)) +- **LSP semantic diagnostics on every write** — `write_file` and `patch` now run real language-server diagnostics on the post-edit file (delta-only) and surface real errors before they ship downstream. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978)) -- **`vision_analyze` returns pixels to vision-capable models** — When you point the agent at an image with `vision_analyze` and the active model can actually see (GPT-5, Claude, Gemini, Grok-vision), Hermes now passes the raw pixels straight to the model instead of converting them to a text description first. You get the model's actual visual reasoning instead of a degraded text-summary round-trip. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955)) +- **Per-turn file-mutation verifier footer** — after every turn that wrote files, the agent gets a verifier footer summarizing what actually changed on disk — catches silent overwrites and "wrote it but it didn't land" bugs. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498)) -- **Per-turn file-mutation verifier footer** — After every turn that wrote or edited files, the agent now gets a short footer summarizing exactly what changed on disk — the file paths, the line counts, the actual delta. That means the agent catches its own mistakes when a write didn't land or got silently overwritten, instead of confidently telling you "I added the function" when the file wasn't actually saved. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498)) +- **Unified `video_generate` with pluggable provider backends** — single tool, any backend. Drop in a new video provider as a plugin, no core changes. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126)) -- **LSP semantic diagnostics on every write** — When the agent uses `write_file` or `patch`, Hermes now runs a real language server against the edited file and surfaces any new errors back to the agent before the next turn. Type errors, undefined symbols, missing imports — caught immediately. Goes way beyond v0.13.0's basic Python/JSON/YAML/TOML linting because it's actual semantic analysis. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978)) +- **`computer_use` cua-driver backend** — proper focus-safe ops, non-Anthropic provider support, refresh on `hermes update`. Computer-use is no longer locked to a single SDK. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063)) -- **Unified `video_generate` with pluggable provider backends** — One tool, any video model. Hermes ships with the obvious backends already, but you can drop in a new video provider as a plugin without touching core. So when a new video model lands next month, it can be a one-file plugin instead of a fork. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126)) +- **xAI Grok OAuth provider — SuperGrok via subscription** — sign in with your xAI account, talk to Grok models from Hermes. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534)) -- **`computer_use` cua-driver backend — works with non-Anthropic models now** — Computer-use (the agent controlling your mouse and keyboard to drive GUI apps) used to be locked to Anthropic's SDK. The new cua-driver backend works with non-Anthropic providers too, has proper focus-safe operations, and refreshes itself on `hermes update`. Now any vision-capable model can drive your desktop. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063)) +- **Clarify with buttons — native inline keyboards on Telegram + Discord** — the `clarify` tool renders multi-choice prompts as platform-native buttons instead of typed responses. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485)) -- **Clickable URLs in any terminal** — Links in agent output are now real OSC8 hyperlinks with hover-highlight in any terminal that supports them. Click to open in your browser — no more copy-paste-trim of long URLs from the transcript. Just works in iTerm2, Kitty, Ghostty, modern Windows Terminal, etc. (@OutThisLife) ([#25071](https://github.com/NousResearch/hermes-agent/pull/25071), [#24013](https://github.com/NousResearch/hermes-agent/pull/24013)) +- **Discord channel history backfill (default on)** — Hermes reads recent channel history when joining a thread so it actually knows what's been said. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984)) -- **Zed ACP Registry — `uvx` install in one click** — Hermes is now listed in Zed's Agent Client Protocol registry, so Zed users can install it with one click. The install path uses `uvx` so there's no npm dependency. `hermes acp --setup-browser` bootstraps the browser tools for registry-driven installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234)) +- **Watchers skill — RSS / HTTP JSON / GitHub polling via cron `no_agent` mode** — skill recipes that wire change-detection sources directly into cron's script-only watchdog mode. ([#21881](https://github.com/NousResearch/hermes-agent/pull/21881)) -- **OpenRouter Pareto Code router with `min_coding_score` knob** — OpenRouter's "Pareto" router automatically picks the cheapest model that meets a minimum quality bar. The new `min_coding_score` config lets you set that bar for coding tasks specifically — Hermes routes to the most affordable model that's at least that good at code. Stop paying for top-tier models when a mid-tier one would do. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838)) +- **Zed ACP Registry integration + uvx distribution** — Hermes is in the Zed registry, installable via `uvx` (no npm). Plus `hermes acp --setup-browser` bootstraps browser tools for registry installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234)) -- **NovitaAI as a new model provider** — NovitaAI joins the provider lineup, giving you another option for open-source model hosting (Llama, Qwen, DeepSeek, etc.) with their pricing and rate limits. (salvage #7219) (@kshitijk4poor) ([#25507](https://github.com/NousResearch/hermes-agent/pull/25507)) +- **OpenRouter Pareto Code router** — wire a new OpenRouter router with `min_coding_score` knob. Pick the cheapest model that meets your quality bar. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838)) -- **Codex app-server runtime for OpenAI/Codex models** — An optional runtime that drives OpenAI's Codex CLI under the hood when you're using OpenAI or Codex paths. You get session reuse, automatic retirement of wedged sessions, and proper OAuth refresh classification — the kind of plumbing that makes long agentic runs not fall over. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769)) +- **Optional codex app-server runtime for OpenAI/Codex models** — drives the OpenAI Codex CLI under the hood for OpenAI/Codex paths, with session reuse, wedge retirement, and OAuth refresh classification. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769)) -- **`huggingface/skills` as a trusted default tap** — The community skills index hosted at huggingface.co/skills is now wired into the Skills Hub by default. So when somebody publishes a useful skill there, you can install it from your own `hermes skills` browser without any extra config. (closes #2549) ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219)) +- **`hermes-skills/huggingface` as a trusted default tap** — community skills index from huggingface.co/skills is available by default in the Skills Hub. ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219)) -- **9 new optional skills** — Hyperliquid (perp + spot trading via the SDK and REST API), Yahoo Finance (live market data, fundamentals, historicals), api-testing (REST + GraphQL debug recipes), unified EVM multi-chain (one skill covers Ethereum + L2s + Base), darwinian-evolver (evolutionary prompt/skill tuning), osint-investigation (OSINT recipes for people / domains / orgs), pinggy-tunnel (expose local services to the public internet), watchers (polls RSS / HTTP JSON / GitHub via cron `no_agent` mode for change detection), and a full Notion overhaul for the May 2026 Developer Platform. ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612)) +- **9 new optional skills** — Hyperliquid (perp/spot trading via SDK + REST) (@kshitijk4poor & Hermes), Yahoo Finance market data, api-testing (REST/GraphQL debug), unified EVM multi-chain skill (folds #25291 + #2010 + base/), darwinian-evolver, osint-investigation (closes #355), pinggy-tunnel, watchers (RSS/HTTP/GitHub via cron), Notion overhaul for the Developer Platform (May 2026). ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612)) -- **API server exposes run approval events** — If you're driving Hermes programmatically through the HTTP API, long-running runs no longer silently hang when the agent hits an approval-required command. The approval request now surfaces on the API stream so your client can prompt the user and reply — no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899)) +- **API server exposes run approval events** — long-running runs surface approval requests over the API stream, no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899)) -- **Plugins can run any LLM call via `ctx.llm` + replace built-in tools via `tool_override`** — If you're writing a Hermes plugin, you now get first-class access to make LLM calls through the active provider and credentials — no manual client wiring. The new `tool_override` flag lets a plugin swap out a built-in tool with its own implementation cleanly. Plugin authors get the same model-routing and auth plumbing the core agent uses. (closes #11049) ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759)) +- **`/subgoal` — user-added criteria appended to active `/goal`** — layer extra success criteria onto a running goal loop. The judge sees them in the prompt, no behavior change when subgoals are empty. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449)) -- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — Two new free web-search backends join Tavily, SearXNG, and Exa. Brave Search has a generous free tier; DDGS is the DuckDuckGo scraper that needs no key at all. Pick whichever fits your budget and rate-limit needs. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337)) +- **Plugins can run any LLM call via `ctx.llm`** — plugins get a first-class hook to make their own LLM requests through the active provider/credentials, no manual wiring. Plus `tool_override` flag for replacing built-in tools. ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759)) -- **Sudo brute-force block + 3 dangerous-command bypasses closed + tool-error sanitization** — The approval gate now blocks `sudo -S` brute-force attempts and classifies stdin-fed or askpass-stripped sudo invocations as DANGEROUS. Three known bypasses of dangerous-command detection are closed (inspired by Claude Code's command-detection work). And tool error strings are now sanitized before being re-injected into the model context, so a malicious file or remote service can't pass instructions to your agent through error output. ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736), [#26829](https://github.com/NousResearch/hermes-agent/pull/26829), [#26823](https://github.com/NousResearch/hermes-agent/pull/26823)) +- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — two new free search backends alongside Tavily / SearXNG / Exa. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337)) -- **`/subgoal` — user-added criteria appended to an active `/goal`** — When you've got a `/goal` running (the persistent Ralph-loop goal where the agent keeps going until criteria are met), you can now use `/subgoal ` to layer extra success criteria onto it mid-run. The judge factors your new criteria into the done-or-keep-going decision without restarting the loop. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449)) +- **Sudo brute-force block + sudo-stdin/askpass DANGEROUS classification** — closes the `sudo -S` brute-force avenue; approval gates classify stdin-fed and askpass-stripped sudo invocations as dangerous. (salvages of #22194 + #21128) ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736)) -- **Provider rename — Alibaba Cloud → Qwen Cloud** — The Alibaba Cloud provider is renamed to Qwen Cloud in the picker and config to match what the rest of the world calls it. Existing config keys still work — no breaking changes — but the UI matches the actual brand now. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835)) - -- **Native Windows support (early beta)** — Hermes now runs natively on `cmd.exe` and PowerShell without WSL. A full PowerShell installer handles MinGit auto-install, Microsoft Store python stub detection, and the foreground Ctrl+C dance. There's still rough edges (this is the "early beta" stamp) — ~40 follow-up Windows-only fixes already landed in the window — but the basic loop works end-to-end on a clean Windows box. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561)) +- **Provider rename — Alibaba Cloud → Qwen Cloud, picker reorder** — matches what the world calls it. Existing config keys still work. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835)) --- diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index 6f785af54..f22176f93 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -15,18 +15,6 @@ and MoonshotAI/kimi-cli#1595: 2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not the parent. Presence of both causes "type should be defined in anyOf items instead of the parent schema". -3. ``enum`` arrays on scalar-typed nodes may not contain ``null`` or empty - strings. Strip those entries (drop the enum entirely if it becomes empty). -4. ``$ref`` nodes may not carry sibling keywords. Moonshot expands the - reference before validation and then rejects the node if sibling keys - like ``description`` remain on the same node as ``$ref``. Strip every - sibling from ``$ref`` nodes so only ``{"$ref": "..."}`` survives. - (Ported from anomalyco/opencode#24730.) -5. ``items`` may not be a tuple-style array (``items: [schemaA, schemaB]`` - for positional element schemas). Moonshot's schema engine requires a - single object schema applied to every array element. Collapse tuple - ``items`` to the first element schema (or ``{}`` if the tuple is empty). - (Ported from anomalyco/opencode#24730.) The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it @@ -78,16 +66,6 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: } elif key in _SCHEMA_LIST_KEYS and isinstance(value, list): repaired[key] = [_repair_schema(v, is_schema=True) for v in value] - elif key == "items" and isinstance(value, list): - # Rule 5: tuple-style ``items`` arrays (positional element - # schemas) are not accepted by Moonshot. Collapse to the - # first element schema if present, else to ``{}``. This - # matches opencode's behaviour for moonshotai / kimi models. - first = value[0] if value else {} - if isinstance(first, dict): - repaired[key] = _repair_schema(first, is_schema=True) - else: - repaired[key] = first elif key in _SCHEMA_NODE_KEYS: # items / not / additionalProperties: single nested schema. # additionalProperties can also be a bool — leave those alone. @@ -152,15 +130,6 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: else: repaired.pop("enum") - # Rule 4: $ref nodes must not have sibling keywords. Moonshot expands - # the reference before validation and then rejects the node if siblings - # like ``description`` / ``type`` / ``default`` appear alongside $ref. - # The referenced definition still carries its own description on the - # target node, which Moonshot accepts. - # (Ported from anomalyco/opencode#24730.) - if "$ref" in repaired: - return {"$ref": repaired["$ref"]} - return repaired diff --git a/apps/bootstrap-installer/.gitignore b/apps/bootstrap-installer/.gitignore new file mode 100644 index 000000000..bc961ce5a --- /dev/null +++ b/apps/bootstrap-installer/.gitignore @@ -0,0 +1,40 @@ +# Rust / Cargo +/src-tauri/target/ +/src-tauri/Cargo.lock + +# Vite / build output +/dist/ +/dist-ssr/ +*.local + +# TypeScript build info + tsc emit (we don't ship .js for the +# vite.config.ts; Vite reads it directly via ts-node-style loader). +*.tsbuildinfo +vite.config.d.ts +vite.config.js + +# Tauri generated artifacts (regenerated on each build) +/src-tauri/gen/schemas/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor +.vscode/* +!.vscode/extensions.json +.idea/ +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Node +node_modules/ + +# Internal placeholder (re-create if needed) +.tauri-note diff --git a/apps/bootstrap-installer/index.html b/apps/bootstrap-installer/index.html new file mode 100644 index 000000000..1b34980a9 --- /dev/null +++ b/apps/bootstrap-installer/index.html @@ -0,0 +1,12 @@ + + + + + + Hermes Setup + + +
+ + + diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json new file mode 100644 index 000000000..6b7991eaf --- /dev/null +++ b/apps/bootstrap-installer/package.json @@ -0,0 +1,46 @@ +{ + "name": "@hermes/bootstrap-installer", + "private": true, + "version": "0.0.1", + "description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5175", + "build": "tsc -b && vite build", + "preview": "vite preview", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "tauri:build:debug": "tauri build --debug" + }, + "dependencies": { + "@nous-research/ui": "0.16.0", + "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/typography": "^0.5.19", + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-process": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "@vscode/codicons": "^0.0.45", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "katex": "^0.16.45", + "lucide-react": "^0.577.0", + "nanostores": "^1.3.0", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-shimmer": "^0.4.11" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "~5.9.3", + "vite": "^7.3.1" + } +} diff --git a/apps/bootstrap-installer/src-tauri/Cargo.toml b/apps/bootstrap-installer/src-tauri/Cargo.toml new file mode 100644 index 000000000..fe65ff9aa --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "hermes-bootstrap" +version = "0.0.1" +description = "Hermes Setup — signed installer that drives scripts/install.ps1" +authors = ["Nous Research "] +edition = "2021" +rust-version = "1.77" + +# Rename the output binary so the distributed artifact is literally +# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees +# what we hand her, period. Tauri honors [[bin]] over [package].name +# for the produced executable name. +[[bin]] +name = "Hermes-Setup" +path = "src/main.rs" + +# The library target name MUST match the `withGlobalTauri` binding name that +# tauri.conf.json's `app.windows[].label` references. We don't ship a separate +# lib for now; everything is in src/. +[lib] +name = "hermes_bootstrap_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +# Tauri runtime + plugins +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-opener = "2" +tauri-plugin-process = "2" +tauri-plugin-shell = "2" + +# Async + IO +tokio = { version = "1", features = ["full"] } +futures = "0.3" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# HTTP — rustls so we don't need OpenSSL on the build box +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } + +# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the +# webview console via Tauri's event channel. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tracing-appender = "0.2" + +# Paths + utils +dirs = "5" +which = "6" +anyhow = "1" +thiserror = "1" +once_cell = "1" +uuid = { version = "1", features = ["v4"] } + +# Process control on Windows (CREATE_NO_WINDOW etc.) +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_Console", + "Win32_UI_WindowsAndMessaging", +] } + +[profile.release] +# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit. +panic = "abort" +codegen-units = 1 +lto = true +opt-level = "s" +strip = true diff --git a/apps/bootstrap-installer/src-tauri/build.rs b/apps/bootstrap-installer/src-tauri/build.rs new file mode 100644 index 000000000..dbf3ba5fe --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/build.rs @@ -0,0 +1,150 @@ +use std::process::Command; + +fn main() { + // ----------------------------------------------------------------- + // Bake the install.ps1 pin into the binary at compile time. + // + // BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's + // `option_env!()` macro to default the install-script reference. + // Precedence (matches install.ps1's own arg precedence): commit > branch. + // + // Resolution order: + // 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.). + // Useful for CI builds that want to pin to a tagged release SHA + // rather than whatever the checkout's HEAD happens to be. + // 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against + // the repo this build.rs lives in. Default for `cargo tauri build` + // from a dev machine — pins the produced .exe to your current + // checkout state. + // 3. Last-resort fallback: hardcoded `main` branch, no commit. The + // installer will fetch HEAD-of-main at runtime. Used when the + // build is happening outside a git checkout (e.g. cargo install + // from a packaged crate, unlikely for this binary but defensive). + // + // Build script reruns on git HEAD change so a new commit triggers + // a rebuild without `cargo clean`. + // ----------------------------------------------------------------- + + let commit = resolve_commit_pin(); + let branch = resolve_branch_pin(); + + if let Some(c) = &commit { + println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}"); + println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c)); + } + if let Some(b) = &branch { + println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}"); + println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"); + } + if commit.is_none() && branch.is_none() { + // Fail loudly rather than silently produce a binary that errors + // at runtime with "no install-script pin supplied". A build that + // can't resolve a pin almost certainly indicates a misconfigured + // build environment. + println!( + "cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args" + ); + } + + // Rerun build.rs when HEAD moves so successive builds pick up new + // commits without needing `cargo clean`. .git/HEAD changes on every + // commit / branch switch / rebase. + let git_dir = locate_git_dir(); + if let Some(gd) = &git_dir { + println!("cargo:rerun-if-changed={}/HEAD", gd.display()); + // .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`); + // also watch the ref itself so a new commit on the same branch + // re-triggers. + if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) { + if let Some(rest) = head.trim().strip_prefix("ref: ") { + println!("cargo:rerun-if-changed={}/{}", gd.display(), rest); + } + } + } + println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT"); + println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH"); + + // ----------------------------------------------------------------- + // Tauri windows manifest. See hermes-setup.manifest for rationale — + // declares level="asInvoker" so Windows's installer-detection + // heuristic doesn't refuse to launch us without UAC elevation. + // ----------------------------------------------------------------- + #[cfg(target_os = "windows")] + let attrs = { + let manifest = include_str!("hermes-setup.manifest"); + let win = tauri_build::WindowsAttributes::new().app_manifest(manifest); + tauri_build::Attributes::new().windows_attributes(win) + }; + + #[cfg(not(target_os = "windows"))] + let attrs = tauri_build::Attributes::new(); + + tauri_build::try_build(attrs).expect("failed to run tauri-build"); +} + +fn resolve_commit_pin() -> Option { + if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + let out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn resolve_branch_pin() -> Option { + if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + let out = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?.trim().to_string(); + // "HEAD" is what you get on a detached checkout — no meaningful branch + // to pin to. The commit pin still applies; just don't emit a branch. + if s.is_empty() || s == "HEAD" { + None + } else { + Some(s) + } +} + +fn locate_git_dir() -> Option { + let out = Command::new("git") + .args(["rev-parse", "--git-dir"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if s.is_empty() { + return None; + } + Some(std::path::PathBuf::from(s)) +} + +fn short(commit: &str) -> &str { + if commit.len() >= 12 { + &commit[..12] + } else { + commit + } +} diff --git a/apps/bootstrap-installer/src-tauri/capabilities/default.json b/apps/bootstrap-installer/src-tauri/capabilities/default.json new file mode 100644 index 000000000..e07617ce0 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/capabilities/default.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://schema.tauri.app/config/2/capability", + "identifier": "default", + "description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:event:default", + "opener:default", + "dialog:default", + "process:default", + "shell:default" + ] +} diff --git a/apps/bootstrap-installer/src-tauri/hermes-setup.manifest b/apps/bootstrap-installer/src-tauri/hermes-setup.manifest new file mode 100644 index 000000000..d7da599b3 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/hermes-setup.manifest @@ -0,0 +1,75 @@ + + + + + Hermes Setup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PerMonitorV2 + UTF-8 + + + + + + + + + + diff --git a/apps/bootstrap-installer/src-tauri/icons/128x128.png b/apps/bootstrap-installer/src-tauri/icons/128x128.png new file mode 100644 index 000000000..e0f04fe72 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/128x128.png differ diff --git a/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png b/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png new file mode 100644 index 000000000..e0f04fe72 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png differ diff --git a/apps/bootstrap-installer/src-tauri/icons/32x32.png b/apps/bootstrap-installer/src-tauri/icons/32x32.png new file mode 100644 index 000000000..e0f04fe72 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/32x32.png differ diff --git a/apps/bootstrap-installer/src-tauri/icons/icon.icns b/apps/bootstrap-installer/src-tauri/icons/icon.icns new file mode 100644 index 000000000..e173b26ee Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/icon.icns differ diff --git a/apps/bootstrap-installer/src-tauri/icons/icon.ico b/apps/bootstrap-installer/src-tauri/icons/icon.ico new file mode 100644 index 000000000..eaa48ff2d Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/icon.ico differ diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs new file mode 100644 index 000000000..529b3b447 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -0,0 +1,712 @@ +//! Bootstrap orchestration. +//! +//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`. +//! Drives install.ps1 / install.sh stage-by-stage, emits progress events +//! over the Tauri `bootstrap` channel, writes a forensic log to +//! HERMES_HOME/logs/bootstrap-.log. +//! +//! Lifecycle: +//! 1. `start_bootstrap` (Tauri command) → spawns the worker task. +//! 2. Worker resolves install script (dev/cache/download). +//! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event. +//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`. +//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::{mpsc, Mutex}; + +use crate::events::{BootstrapEvent, Manifest, StageState}; +use crate::install_script::{self, Pin, ScriptKind, ScriptSource}; +use crate::powershell::{self, StreamSink}; +use crate::AppState; + +// --------------------------------------------------------------------------- +// Public Tauri commands +// --------------------------------------------------------------------------- + +/// Frontend → Rust: kick off the install. +#[derive(Debug, Deserialize)] +pub struct StartBootstrapArgs { + /// Optional override for the commit pin. Defaults to the build-time + /// pin baked in via `BUILD_PIN_COMMIT`. + pub commit: Option, + /// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`. + pub branch: Option, + /// Include Stage-Desktop (build apps/desktop) in the manifest. The + /// signed bootstrap installer passes true; the deprecated Electron-side + /// bootstrap-runner passes false to avoid building-while-running. + #[serde(default = "default_true")] + pub include_desktop: bool, + /// Optional override for HERMES_HOME. Tests use this; production + /// almost always falls back to the OS default. + pub hermes_home: Option, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Serialize)] +pub struct BootstrapStatus { + pub running: bool, + pub completed: bool, + pub install_root: Option, + pub last_error: Option, +} + +/// Handle stored in AppState while a bootstrap run is in flight. Carries +/// the cancellation channel and the most recent terminal status so the +/// frontend can re-query after a window refresh. +pub struct BootstrapHandle { + pub cancel_tx: mpsc::Sender<()>, + pub started_at: Instant, + pub status: BootstrapStatus, +} + +#[tauri::command] +pub async fn start_bootstrap( + app: AppHandle, + state: State<'_, Arc>, + args: StartBootstrapArgs, +) -> Result<(), String> { + let mut guard = state.bootstrap.lock().await; + if let Some(h) = guard.as_ref() { + if h.status.running { + return Err("Bootstrap is already running".into()); + } + } + + let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1); + let handle = BootstrapHandle { + cancel_tx, + started_at: Instant::now(), + status: BootstrapStatus { + running: true, + completed: false, + install_root: None, + last_error: None, + }, + }; + *guard = Some(handle); + drop(guard); + + let app_for_task = app.clone(); + let state_for_task = state.inner().clone(); + let args_for_task = args; + let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx))); + + tokio::spawn(async move { + let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await; + + // Reflect terminal state into AppState so get_bootstrap_status() + // can serve it after the task exits. + let mut guard = state_for_task.bootstrap.lock().await; + if let Some(h) = guard.as_mut() { + h.status.running = false; + match &result { + Ok(install_root) => { + h.status.completed = true; + h.status.install_root = Some(install_root.clone()); + h.status.last_error = None; + } + Err(err) => { + h.status.completed = false; + h.status.last_error = Some(err.to_string()); + } + } + } + }); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_bootstrap(state: State<'_, Arc>) -> Result<(), String> { + let guard = state.bootstrap.lock().await; + if let Some(h) = guard.as_ref() { + let _ = h.cancel_tx.try_send(()); + } + Ok(()) +} + +#[tauri::command] +pub async fn get_bootstrap_status( + state: State<'_, Arc>, +) -> Result { + let guard = state.bootstrap.lock().await; + Ok(match guard.as_ref() { + Some(h) => BootstrapStatus { + running: h.status.running, + completed: h.status.completed, + install_root: h.status.install_root.clone(), + last_error: h.status.last_error.clone(), + }, + None => BootstrapStatus { + running: false, + completed: false, + install_root: None, + last_error: None, + }, + }) +} + +/// Spawn the locally-built Hermes desktop binary, then close the installer +/// window. Caller resolves the binary path from `install_root`. +/// +/// Returns Err with a human-readable message if the binary doesn't exist +/// (e.g. when Stage-Desktop was skipped) so the frontend can present +/// actionable failure UI rather than silently doing nothing. +#[tauri::command] +pub async fn launch_hermes_desktop( + app: AppHandle, + install_root: String, +) -> Result<(), String> { + let install_root = PathBuf::from(install_root); + let exe_path = resolve_hermes_desktop_exe(&install_root).ok_or_else(|| { + format!( + "Couldn't find a built Hermes desktop at {}. The desktop build step \ + may have been skipped or failed. Run `hermes desktop` from a \ + terminal to build and launch it.", + install_root.join("apps").join("desktop").join("release").display() + ) + })?; + + tracing::info!(?exe_path, "launching Hermes desktop"); + + // Detach from us — the installer is about to exit. + let mut cmd = tokio::process::Command::new(&exe_path); + cmd.current_dir(exe_path.parent().unwrap_or(&install_root)); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + // DETACHED_PROCESS = 0x00000008 + cmd.creation_flags(0x0000_0008); + } + + cmd.spawn().map_err(|e| { + format!( + "failed to launch {}: {e}", + exe_path.display() + ) + })?; + + // Give Windows ~150ms to actually start the new process before we exit. + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + + // Exit the installer cleanly. Tauri's process plugin gives us the + // right hook regardless of platform. + app.exit(0); + Ok(()) +} + +/// Walks the well-known electron-builder unpacked-app paths under +/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/ +/// -unpacked/). +fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option { + let release_dir = install_root.join("apps").join("desktop").join("release"); + let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") { + &[ + ("win-unpacked", "Hermes.exe"), + ("win-arm64-unpacked", "Hermes.exe"), + ] + } else if cfg!(target_os = "macos") { + &[ + ("mac/Hermes.app/Contents/MacOS", "Hermes"), + ("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"), + ] + } else { + &[("linux-unpacked", "hermes")] + }; + for (subdir, exe) in candidates { + let p = release_dir.join(subdir).join(exe); + if p.exists() { + return Some(p); + } + } + None +} + +// --------------------------------------------------------------------------- +// Bootstrap implementation +// --------------------------------------------------------------------------- + +async fn run_bootstrap( + app: AppHandle, + args: StartBootstrapArgs, + cancel_rx_holder: Arc>>>, +) -> Result { + let kind = ScriptKind::for_current_os(); + + let pin = Pin { + commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")), + branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")), + }; + + tracing::info!( + ?pin, + kind = ?kind, + include_desktop = args.include_desktop, + "bootstrap starting" + ); + + let app_for_log = app.clone(); + let emit_log = move |line: &str| { + emit_event( + &app_for_log, + BootstrapEvent::Log { + stage: None, + line: line.to_string(), + }, + ); + // Bump to info-level so the line shows in bootstrap-installer.log + // under the default INFO filter. Previously this was debug! which + // got dropped on the floor, leaving us blind whenever install.ps1 + // failed — the log only had the "bootstrap starting" banner. + tracing::info!(target: "bootstrap.log", "{line}"); + }; + + // 1. Resolve install.ps1 + let script = install_script::resolve(kind, &pin, &emit_log) + .await + .map_err(|e| { + let msg = format!("resolve install script failed: {e:#}"); + emit_event( + &app, + BootstrapEvent::Failed { + stage: None, + error: msg.clone(), + }, + ); + anyhow!(msg) + })?; + + let source_note = match &script.source { + ScriptSource::DevCheckout => "dev checkout", + ScriptSource::Bundled => "bundled", + ScriptSource::Cached => "cached", + ScriptSource::Downloaded => "downloaded", + }; + emit_log(&format!( + "[bootstrap] script {} via {}", + script.path.display(), + source_note + )); + + // 2. Fetch manifest + // + // -IncludeDesktop MUST be passed to the manifest call too — install.ps1 + // gates the desktop stage inclusion on this flag, so without it here + // the manifest comes back missing the desktop stage and we never run + // it. The per-stage call below also passes -IncludeDesktop to keep + // the contracts identical. + let manifest_args = build_pin_args(&script); + let mut manifest_args_full = vec!["-Manifest".to_string()]; + manifest_args_full.extend(manifest_args.clone()); + if args.include_desktop { + manifest_args_full.push("-IncludeDesktop".to_string()); + } + + let manifest_result = run_install_script( + &app, + &script.path, + &manifest_args_full, + args.hermes_home.as_deref(), + None, + Some("__manifest__".to_string()), + ) + .await?; + + if manifest_result.exit_code != Some(0) { + let err = format!( + "install.ps1 -Manifest failed: exit {:?}\n{}", + manifest_result.exit_code, + manifest_result.stderr.trim() + ); + emit_event( + &app, + BootstrapEvent::Failed { + stage: None, + error: err.clone(), + }, + ); + return Err(anyhow!(err)); + } + + let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| { + let err = format!( + "install.ps1 -Manifest produced no parseable JSON payload\n{}", + truncate(&manifest_result.stdout, 4000) + ); + emit_event( + &app, + BootstrapEvent::Failed { + stage: None, + error: err.clone(), + }, + ); + anyhow!(err) + })?; + + emit_event( + &app, + BootstrapEvent::Manifest { + stages: manifest.stages.clone(), + protocol_version: manifest.protocol_version, + }, + ); + + // 3. Iterate stages. + for stage in &manifest.stages { + // Skip Stage-Desktop unless explicitly requested. install.ps1 may + // or may not include it in the manifest depending on the flag we + // pass, but if it slipped in, gate client-side too. + if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") { + emit_event( + &app, + BootstrapEvent::Stage { + name: stage.name.clone(), + state: StageState::Skipped, + duration_ms: Some(0), + result: None, + error: Some("skipped by include_desktop=false".into()), + }, + ); + continue; + } + + if cancellation_signalled(&cancel_rx_holder).await { + let err = "bootstrap cancelled by user".to_string(); + emit_event( + &app, + BootstrapEvent::Failed { + stage: Some(stage.name.clone()), + error: err.clone(), + }, + ); + return Err(anyhow!(err)); + } + + let started = Instant::now(); + emit_event( + &app, + BootstrapEvent::Stage { + name: stage.name.clone(), + state: StageState::Running, + duration_ms: None, + result: None, + error: None, + }, + ); + + let mut stage_args = vec![ + "-Stage".to_string(), + stage.name.clone(), + "-NonInteractive".to_string(), + "-Json".to_string(), + ]; + stage_args.extend(manifest_args.clone()); + if args.include_desktop { + stage_args.push("-IncludeDesktop".to_string()); + } + + // Each stage gets its own cancel receiver because tokio::select! + // in run_script consumes it. Take/return through the Arc. + let local_cancel_rx = cancel_rx_holder.lock().await.take(); + + let stage_result = run_install_script( + &app, + &script.path, + &stage_args, + args.hermes_home.as_deref(), + local_cancel_rx, + Some(stage.name.clone()), + ) + .await?; + + let duration_ms = started.elapsed().as_millis() as u64; + + if stage_result.killed { + emit_event( + &app, + BootstrapEvent::Stage { + name: stage.name.clone(), + state: StageState::Failed, + duration_ms: Some(duration_ms), + result: None, + error: Some("cancelled by user".into()), + }, + ); + emit_event( + &app, + BootstrapEvent::Failed { + stage: Some(stage.name.clone()), + error: "cancelled by user".into(), + }, + ); + return Err(anyhow!("cancelled by user")); + } + + let result_frame = powershell::parse_stage_result(&stage_result.stdout); + + match result_frame { + None => { + let err = format!( + "install.ps1 -Stage {} produced no JSON result frame (exit={:?})", + stage.name, stage_result.exit_code + ); + emit_event( + &app, + BootstrapEvent::Stage { + name: stage.name.clone(), + state: StageState::Failed, + duration_ms: Some(duration_ms), + result: None, + error: Some(err.clone()), + }, + ); + emit_event( + &app, + BootstrapEvent::Failed { + stage: Some(stage.name.clone()), + error: err.clone(), + }, + ); + return Err(anyhow!(err)); + } + Some(frame) if frame.ok && frame.skipped => { + emit_event( + &app, + BootstrapEvent::Stage { + name: stage.name.clone(), + state: StageState::Skipped, + duration_ms: Some(duration_ms), + result: Some(frame), + error: None, + }, + ); + } + Some(frame) if frame.ok => { + emit_event( + &app, + BootstrapEvent::Stage { + name: stage.name.clone(), + state: StageState::Succeeded, + duration_ms: Some(duration_ms), + result: Some(frame), + error: None, + }, + ); + } + Some(frame) => { + let err = frame + .reason + .clone() + .unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code)); + emit_event( + &app, + BootstrapEvent::Stage { + name: stage.name.clone(), + state: StageState::Failed, + duration_ms: Some(duration_ms), + result: Some(frame), + error: Some(err.clone()), + }, + ); + emit_event( + &app, + BootstrapEvent::Failed { + stage: Some(stage.name.clone()), + error: err.clone(), + }, + ); + return Err(anyhow!(err)); + } + } + } + + // 4. Resolve install_root. install.ps1 doesn't (yet) report this back + // explicitly; we infer it from $HermesHome which Stage-Repository clones + // the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants. + let hermes_home = args + .hermes_home + .clone() + .unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned()); + let install_root = PathBuf::from(&hermes_home).join("hermes-agent"); + + // Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can + // re-invoke us with `--update` and shortcuts have a stable target. This is + // a one-shot install concern; an `--update` re-invocation no-ops because + // we're already running from that path. Best-effort — a failure here must + // not fail an otherwise-successful install. + if let Err(err) = crate::paths::copy_self_to_hermes_home() { + tracing::warn!(?err, "failed to copy installer into HERMES_HOME (non-fatal)"); + emit_log(&format!( + "[bootstrap] warning: could not stage updater binary: {err}" + )); + } + + emit_event( + &app, + BootstrapEvent::Complete { + install_root: install_root.to_string_lossy().into_owned(), + marker: Some(serde_json::json!({ + "pinnedCommit": pin.commit, + "pinnedBranch": pin.branch, + })), + }, + ); + + Ok(install_root.to_string_lossy().into_owned()) +} + +async fn cancellation_signalled(holder: &Arc>>>) -> bool { + let mut guard = holder.lock().await; + if let Some(rx) = guard.as_mut() { + rx.try_recv().is_ok() + } else { + false + } +} + +async fn run_install_script( + app: &AppHandle, + script_path: &std::path::Path, + args: &[String], + hermes_home_override: Option<&str>, + cancel_rx: Option>, + stage_name: Option, +) -> Result { + let app_for_stdout = app.clone(); + let stage_for_stdout = stage_name.clone(); + let app_for_stderr = app.clone(); + let stage_for_stderr = stage_name.clone(); + let stage_for_stdout_log = stage_name.clone(); + let stage_for_stderr_log = stage_name.clone(); + + let sink = StreamSink { + on_stdout_line: Box::new(move |line: &str| { + emit_event( + &app_for_stdout, + BootstrapEvent::Log { + stage: stage_for_stdout.clone(), + line: line.to_string(), + }, + ); + // Tee to the rolling installer log so we have a persistent + // record of every install.ps1 line. Without this, the only + // log evidence of a failure was the Tauri event stream — + // which gets discarded the moment the failure route mounts. + match &stage_for_stdout_log { + Some(name) => { + tracing::info!(target: "bootstrap.log", stage = %name, "{line}") + } + None => tracing::info!(target: "bootstrap.log", "{line}"), + } + }), + on_stderr_line: Box::new(move |line: &str| { + emit_event( + &app_for_stderr, + BootstrapEvent::Log { + stage: stage_for_stderr.clone(), + line: format!("stderr: {line}"), + }, + ); + // stderr-level lines get warn! so they're visually distinct + // when scrolling through the log later. + match &stage_for_stderr_log { + Some(name) => { + tracing::warn!(target: "bootstrap.log", stage = %name, "stderr: {line}") + } + None => tracing::warn!(target: "bootstrap.log", "stderr: {line}"), + } + }), + }; + + powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx) + .await + .map_err(|e| { + tracing::error!(?e, "install script invocation failed"); + anyhow!("install script invocation failed: {e:#}") + }) +} + +fn build_pin_args(script: &install_script::ResolvedScript) -> Vec { + let mut out = Vec::new(); + if let Some(c) = &script.commit { + out.push("-Commit".to_string()); + out.push(c.clone()); + } + if let Some(b) = &script.branch { + out.push("-Branch".to_string()); + out.push(b.clone()); + } + out +} + +fn emit_event(app: &AppHandle, event: BootstrapEvent) { + // Tee important state transitions to the rolling installer log so + // bootstrap-installer.log isn't just "starting" + final summary. + // Log lines (the noisy stuff) handle their own tracing in + // run_install_script's sink; here we cover the lifecycle frames. + match &event { + BootstrapEvent::Manifest { stages, .. } => { + tracing::info!( + stage_count = stages.len(), + names = ?stages.iter().map(|s| s.name.as_str()).collect::>(), + "manifest received" + ); + } + BootstrapEvent::Stage { + name, + state, + duration_ms, + error, + .. + } => { + tracing::info!( + stage = %name, + ?state, + duration_ms = ?duration_ms, + error = ?error, + "stage transition" + ); + } + BootstrapEvent::Complete { install_root, .. } => { + tracing::info!(install_root = %install_root, "bootstrap complete"); + } + BootstrapEvent::Failed { stage, error } => { + tracing::error!(stage = ?stage, error = %error, "bootstrap FAILED"); + } + BootstrapEvent::Log { .. } => { + // Log lines are teed via the sink callbacks in + // run_install_script — don't double-emit here. + } + } + if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) { + tracing::warn!(?e, "failed to emit bootstrap event"); + } +} + +fn option_env_string(key: &str) -> Option { + // option_env! only accepts literals, so we hardcode the known keys. + let val = match key { + "BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"), + "BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"), + _ => None, + }; + val.map(|s| s.to_string()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max]) + } +} diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs new file mode 100644 index 000000000..2add0f54b --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/events.rs @@ -0,0 +1,99 @@ +//! Event types streamed from Rust → React. +//! +//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape +//! 1:1 so the React installer code can be roughly identical to the Electron +//! install-overlay we'll replace. +//! +//! The Tauri event channel name is `"bootstrap"` for all of these — the +//! `type` discriminator on each payload is how the frontend routes. + +use serde::{Deserialize, Serialize}; + +/// Stage definition as reported by `install.ps1 -Manifest`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageInfo { + pub name: String, + pub title: String, + pub category: String, + /// `needs_user_input=true` stages run with -NonInteractive and emit + /// skipped=true; the post-install wizard takes over for those. + #[serde(rename = "needs_user_input", alias = "needsUserInput")] + pub needs_user_input: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub stages: Vec, + #[serde(rename = "protocol_version", alias = "protocolVersion", default)] + pub protocol_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageResultPayload { + pub stage: String, + pub ok: bool, + #[serde(default)] + pub skipped: bool, + #[serde(default)] + pub reason: Option, + /// install.ps1 may attach stage-specific structured data here. + #[serde(default)] + pub data: Option, +} + +/// Run-state for a single stage as we transition through it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StageState { + Running, + Succeeded, + Skipped, + Failed, +} + +/// The single event channel `bootstrap` emits these. `type` discriminates. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum BootstrapEvent { + /// Sent once at the start with the full stage list. + Manifest { + stages: Vec, + #[serde(rename = "protocolVersion")] + protocol_version: Option, + }, + /// Stage state transition. `result` populated only on terminal states. + Stage { + name: String, + state: StageState, + #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")] + duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + }, + /// Raw stdout/stderr line from install.ps1 (or our wrapper). + Log { + #[serde(skip_serializing_if = "Option::is_none")] + stage: Option, + line: String, + }, + /// Sent once when all stages complete successfully. + Complete { + #[serde(rename = "installRoot")] + install_root: String, + marker: Option, + }, + /// Sent once if the run aborts. + Failed { + #[serde(skip_serializing_if = "Option::is_none")] + stage: Option, + error: String, + }, +} + +impl BootstrapEvent { + /// Tauri event name. Single channel for all bootstrap events; the + /// `type` tag tells the renderer how to interpret the payload. + pub const CHANNEL: &'static str = "bootstrap"; +} diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs new file mode 100644 index 000000000..217ee9fef --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -0,0 +1,273 @@ +//! Resolves and downloads `scripts/install.ps1` (and `install.sh`). +//! +//! Resolution order: +//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT +//! env var. Lets devs iterate without re-publishing the script. +//! 2. Bundled fallback: if the installer was bundled with a script (e.g. +//! tauri's `resource` mechanism), serve from there. Not used today. +//! 3. Network: download from GitHub raw at a pinned commit or branch. +//! Commit pins are immutable; branch pins are HEAD-tracking. +//! +//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`, +//! but the dev-checkout resolution is driven by an env var rather than the +//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant +//! to live OUTSIDE any repo checkout. + +use anyhow::{anyhow, Context, Result}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; + +use crate::paths; + +/// Identity of the install.ps1 we'll execute. Used by both the manifest +/// fetch and the per-stage runs. +#[derive(Debug, Clone)] +pub struct ResolvedScript { + pub path: PathBuf, + pub source: ScriptSource, + /// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is + /// what makes the repo stage clone the exact tested SHA. + pub commit: Option, + pub branch: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScriptSource { + DevCheckout, + Bundled, + Cached, + Downloaded, +} + +/// What flavor of script (Windows .ps1 vs Unix .sh). +#[derive(Debug, Clone, Copy)] +pub enum ScriptKind { + Ps1, + Sh, +} + +impl ScriptKind { + pub fn for_current_os() -> Self { + if cfg!(target_os = "windows") { + Self::Ps1 + } else { + Self::Sh + } + } + + fn filename(&self) -> &'static str { + match self { + Self::Ps1 => "install.ps1", + Self::Sh => "install.sh", + } + } +} + +/// Validates a string looks like a git SHA (7+ hex chars). Mirrors +/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs. +fn is_valid_commit(s: &str) -> bool { + let len = s.len(); + (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Resolves the install script to use for this run. +/// +/// `pin` is the commit-or-branch from either Hermes-Setup's build-time +/// constant (compiled into the installer) or a runtime override. +pub async fn resolve( + kind: ScriptKind, + pin: &Pin, + emit_log: &impl Fn(&str), +) -> Result { + // 1. Dev shortcut. + if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") { + let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename()); + if candidate.exists() { + emit_log(&format!( + "[bootstrap] dev mode — using local {} at {}", + kind.filename(), + candidate.display() + )); + return Ok(ResolvedScript { + path: candidate, + source: ScriptSource::DevCheckout, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }); + } + } + + // 2. (Not implemented) bundled fallback. + + // 3. Network. Pin must be a real commit or a branch ref. + let commit_or_ref = match (&pin.commit, &pin.branch) { + (Some(c), _) if is_valid_commit(c) => c.clone(), + (_, Some(b)) if !b.trim().is_empty() => b.clone(), + (Some(other), _) => { + return Err(anyhow!( + "install script pin commit `{other}` is not a valid git SHA" + )); + } + _ => { + return Err(anyhow!( + "no install-script pin supplied — installer cannot resolve a script source" + )); + } + }; + + let cached = cached_path(kind, &commit_or_ref); + if cached.exists() { + emit_log(&format!( + "[bootstrap] using cached {} for {}", + kind.filename(), + truncate_ref(&commit_or_ref) + )); + return Ok(ResolvedScript { + path: cached, + source: ScriptSource::Cached, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }); + } + + emit_log(&format!( + "[bootstrap] downloading {} for {} from GitHub", + kind.filename(), + truncate_ref(&commit_or_ref) + )); + + download(kind, &commit_or_ref, &cached).await?; + + emit_log(&format!("[bootstrap] cached to {}", cached.display())); + + Ok(ResolvedScript { + path: cached, + source: ScriptSource::Downloaded, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }) +} + +#[derive(Debug, Clone, Default)] +pub struct Pin { + pub commit: Option, + pub branch: Option, +} + +fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf { + let safe = sanitize_ref(commit_or_ref); + let filename = match kind { + ScriptKind::Ps1 => format!("install-{safe}.ps1"), + ScriptKind::Sh => format!("install-{safe}.sh"), + }; + paths::bootstrap_cache_dir().join(filename) +} + +/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can +/// contain `/`, dots, etc.; we want a flat filename. +fn sanitize_ref(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +fn truncate_ref(s: &str) -> &str { + if is_valid_commit(s) && s.len() >= 12 { + &s[..12] + } else { + s + } +} + +/// Downloads to `dest_path` via reqwest with rustls. Atomically renames +/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache. +async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> { + let url = format!( + "https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}", + commit_or_ref, + kind.filename() + ); + + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("creating bootstrap-cache parent dir {}", parent.display()) + })?; + } + + let tmp_path = dest_path.with_extension({ + let ext = dest_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("tmp"); + format!("{ext}.tmp") + }); + + let response = reqwest::Client::new() + .get(&url) + .header("User-Agent", "hermes-setup/0.0.1") + .send() + .await + .with_context(|| format!("GET {url}"))?; + + if !response.status().is_success() { + return Err(anyhow!( + "Failed to download {}: HTTP {} from {}", + kind.filename(), + response.status(), + url + )); + } + + let bytes = response + .bytes() + .await + .with_context(|| format!("reading body of {url}"))?; + + let mut file = tokio::fs::File::create(&tmp_path) + .await + .with_context(|| format!("creating temp file {}", tmp_path.display()))?; + file.write_all(&bytes) + .await + .with_context(|| format!("writing temp file {}", tmp_path.display()))?; + file.flush().await.context("flushing temp file")?; + drop(file); + + tokio::fs::rename(&tmp_path, dest_path) + .await + .with_context(|| { + format!( + "renaming {} → {}", + tmp_path.display(), + dest_path.display() + ) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_valid_commit_accepts_short_and_full_shas() { + assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0")); + assert!(is_valid_commit("02d2698")); + assert!(!is_valid_commit("02d269")); + assert!(!is_valid_commit("not-a-sha")); + assert!(!is_valid_commit("")); + } + + #[test] + fn sanitize_ref_replaces_slashes() { + assert_eq!(sanitize_ref("bb/gui"), "bb_gui"); + assert_eq!(sanitize_ref("main"), "main"); + assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3"); + } +} diff --git a/apps/bootstrap-installer/src-tauri/src/lib.rs b/apps/bootstrap-installer/src-tauri/src/lib.rs new file mode 100644 index 000000000..a710ce9b5 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/lib.rs @@ -0,0 +1,134 @@ +//! Hermes Setup — Tauri entrypoint. +//! +//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/). +//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri +//! commands registered at the bottom of `run()`. +//! +//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not +//! here — a crate-level attribute on a lib doesn't propagate to the linker +//! flags of the executable that consumes it. + +mod bootstrap; +mod events; +mod install_script; +mod powershell; +mod paths; +mod update; + +use std::sync::Arc; +use tokio::sync::Mutex; + +/// How the installer was invoked. Resolved once from the process args in +/// `run()` and exposed to the frontend via `get_mode` so it can route to the +/// install flow (first-run onboarding) or the update flow (driven by the +/// desktop app handing off via `Hermes-Setup.exe --update`). +/// +/// Bare launch (double-click, first-run) => Install. +/// `--update` (spawned by the desktop's "Update" button) => Update. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AppMode { + Install, + Update, +} + +impl AppMode { + /// Resolve the mode from an argument iterator. Anything containing the + /// `--update` flag selects Update; otherwise Install. Kept arg-iterator + /// generic (not reading `std::env` directly) so it's unit-testable. + pub fn from_args(args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + for a in args { + if a.as_ref() == "--update" { + return AppMode::Update; + } + } + AppMode::Install + } +} + +/// Process-wide install state, shared across Tauri commands. +/// +/// The bootstrap is a one-shot, single-tenant process — we only need one +/// of these per window. `Arc>` lets command handlers grab it +/// without lifetime gymnastics. +pub struct AppState { + pub bootstrap: Mutex>, + /// How this process was launched (install vs update). Immutable for the + /// lifetime of the process; read by the `get_mode` command. + pub mode: AppMode, +} + +impl AppState { + fn new(mode: AppMode) -> Self { + Self { + bootstrap: Mutex::new(None), + mode, + } + } +} + +/// Frontend → Rust: which flow should the UI render? +#[tauri::command] +fn get_mode(state: tauri::State<'_, Arc>) -> AppMode { + state.mode +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + // Tracing → bootstrap-installer.log under HERMES_HOME/logs/ so install + // failures leave a trail for support. Console output also goes here in + // debug builds. + let _guard = paths::init_logging(); + + let mode = AppMode::from_args(std::env::args().skip(1)); + tracing::info!(?mode, "Hermes Setup starting"); + + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_shell::init()) + .manage(Arc::new(AppState::new(mode))) + .invoke_handler(tauri::generate_handler![ + // Mode (install vs update) + get_mode, + // Bootstrap lifecycle + bootstrap::start_bootstrap, + bootstrap::cancel_bootstrap, + bootstrap::get_bootstrap_status, + // Update lifecycle + update::start_update, + // Hand-off + bootstrap::launch_hermes_desktop, + // Diagnostics + paths::get_log_path, + paths::get_hermes_home, + paths::open_log_dir, + ]) + .run(tauri::generate_context!()) + .expect("error while running Hermes Setup"); +} + +#[cfg(test)] +mod tests { + use super::AppMode; + + #[test] + fn bare_args_are_install() { + assert_eq!(AppMode::from_args(Vec::::new()), AppMode::Install); + assert_eq!(AppMode::from_args(["--foo", "bar"]), AppMode::Install); + } + + #[test] + fn update_flag_selects_update() { + assert_eq!(AppMode::from_args(["--update"]), AppMode::Update); + assert_eq!( + AppMode::from_args(["--something", "--update", "--else"]), + AppMode::Update + ); + } +} diff --git a/apps/bootstrap-installer/src-tauri/src/main.rs b/apps/bootstrap-installer/src-tauri/src/main.rs new file mode 100644 index 000000000..f1f3e26b2 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/main.rs @@ -0,0 +1,19 @@ +// Hermes Setup — process entrypoint. All logic lives in lib.rs so it can +// be unit-tested as a library; this file just calls into it. +// +// The windows_subsystem attribute MUST live here on the binary crate +// (not lib.rs) — placing it on the lib was the bug that left a stray +// cmd window behind Hermes-Setup.exe on release builds. +// +// `windows_subsystem = "windows"` strips the console allocation that +// the default `windows_subsystem = "console"` would do, so double-clicking +// the .exe gives you ONLY the Tauri window. +// +// debug_assertions guard: dev builds keep the console so tracing output +// is visible during `cargo tauri dev`. + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + hermes_bootstrap_lib::run() +} diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs new file mode 100644 index 000000000..ad5112e71 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -0,0 +1,168 @@ +//! Filesystem paths + logging setup. +//! +//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI: +//! Windows: %LOCALAPPDATA%\hermes +//! macOS: ~/.hermes +//! Linux: ~/.hermes (override via $HERMES_HOME) +//! +//! NOTE (macOS): Python's get_hermes_home(), scripts/install.sh, and the +//! Electron desktop's resolveHermesHome() ALL use ~/.hermes on macOS — there +//! is no ~/Library/Application Support branch anywhere else. An earlier +//! version of this file used Application Support, which drifted from every +//! other component: the installer wrote the install to one dir and the +//! desktop looked for it in another, so first launch never found the backend. +//! +//! IMPORTANT: this must match exactly. Drift here means install.ps1 +//! writes to one place and the installer reads from another, breaking +//! the bootstrap-complete check. + +use std::path::{Path, PathBuf}; +use tracing_appender::non_blocking::WorkerGuard; + +/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set. +pub fn hermes_home() -> PathBuf { + if let Ok(override_path) = std::env::var("HERMES_HOME") { + if !override_path.trim().is_empty() { + return PathBuf::from(override_path); + } + } + + #[cfg(target_os = "windows")] + { + // %LOCALAPPDATA%\hermes — matches scripts/install.ps1's $HermesHome. + if let Some(local_app_data) = dirs::data_local_dir() { + return local_app_data.join("hermes"); + } + } + + // macOS + Linux + fallback: ~/.hermes (matches Python get_hermes_home(), + // install.sh, and the Electron desktop's resolveHermesHome()). + if let Some(home) = dirs::home_dir() { + return home.join(".hermes"); + } + + // Last resort — current dir, almost certainly wrong but at least + // doesn't panic. + PathBuf::from(".hermes") +} + +pub fn log_dir() -> PathBuf { + hermes_home().join("logs") +} + +pub fn log_path() -> PathBuf { + log_dir().join("bootstrap-installer.log") +} + +pub fn bootstrap_cache_dir() -> PathBuf { + hermes_home().join("bootstrap-cache") +} + +/// Stable location the installer copies itself to after a successful install. +/// The desktop app re-invokes this with `--update`, and the start-menu / +/// desktop shortcuts can point users back to it. Lives directly under +/// HERMES_HOME so it survives repo checkout deletion (unlike anything under +/// hermes-agent/). +/// +/// On Windows this is `%LOCALAPPDATA%\hermes\hermes-setup.exe`; on other +/// platforms the extension differs but the directory is the same. +pub fn installer_dest() -> PathBuf { + let name = if cfg!(target_os = "windows") { + "hermes-setup.exe" + } else { + "hermes-setup" + }; + hermes_home().join(name) +} + +/// Copy the currently-running installer binary to `installer_dest()` so it's +/// available for future `--update` runs and shortcut launches. +/// +/// No-ops (returns Ok) when the running exe is ALREADY the destination — which +/// is exactly the case during an `--update` run (the desktop launched us FROM +/// that path), where copying onto ourselves would be a Windows sharing +/// violation. Best-effort: a failure here must not fail the install, so the +/// caller logs and continues. +pub fn copy_self_to_hermes_home() -> std::io::Result<()> { + let src = std::env::current_exe()?; + let dest = installer_dest(); + + // Skip if we're already running from the destination (update re-invocation + // or a prior copy). canonicalize both so symlinks / 8.3 short paths / case + // differences don't trick us into a self-copy. + let same = match (src.canonicalize(), dest.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => src == dest, + }; + if same { + tracing::info!(?dest, "installer already at destination; skipping self-copy"); + return Ok(()); + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src, &dest)?; + tracing::info!(?src, ?dest, "copied installer to HERMES_HOME"); + Ok(()) +} + +/// Where install.ps1 writes the bootstrap-complete marker (existence-only file +/// the Electron app also checks). Per main.cjs: +/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') +/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so +/// this is a probe helper, not a definitive path. +pub fn likely_bootstrap_marker(install_root: &Path) -> PathBuf { + install_root.join(".hermes-bootstrap-complete") +} + +/// Initializes tracing to bootstrap-installer.log under HERMES_HOME/logs/. +/// Returns a guard that flushes the appender on drop — keep it alive for +/// the lifetime of the process. +pub fn init_logging() -> Option { + let dir = log_dir(); + if let Err(err) = std::fs::create_dir_all(&dir) { + // No log dir → log to stderr only. Don't panic; the installer + // should still be usable on an exotic filesystem. + eprintln!("[hermes-setup] could not create log dir {dir:?}: {err}"); + return None; + } + + let file_appender = tracing_appender::rolling::never(&dir, "bootstrap-installer.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + let env_filter = tracing_subscriber::EnvFilter::try_from_env("HERMES_BOOTSTRAP_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(non_blocking) + .with_ansi(false) + .with_target(true) + .init(); + + Some(guard) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn get_log_path() -> String { + log_path().to_string_lossy().into_owned() +} + +#[tauri::command] +pub fn get_hermes_home() -> String { + hermes_home().to_string_lossy().into_owned() +} + +#[tauri::command] +pub fn open_log_dir(app: tauri::AppHandle) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let path = log_dir(); + app.opener() + .open_path(path.to_string_lossy(), None::<&str>) + .map_err(|e| e.to_string()) +} diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs new file mode 100644 index 000000000..c85d0ee55 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -0,0 +1,267 @@ +//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh. +//! +//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same +//! line-buffered stdout/stderr streaming + cancellation semantics. +//! +//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File + + diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json new file mode 100644 index 000000000..26ae4ea30 --- /dev/null +++ b/apps/desktop/package-lock.json @@ -0,0 +1,18363 @@ +{ + "name": "hermes", + "version": "0.15.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hermes", + "version": "0.15.1", + "dependencies": { + "@assistant-ui/react": "^0.12.28", + "@assistant-ui/react-streamdown": "^0.1.11", + "@audiowave/react": "^0.6.2", + "@chenglou/pretext": "^0.0.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hermes/shared": "file:../shared", + "@nanostores/react": "^1.1.0", + "@nous-research/ui": "^0.13.0", + "@radix-ui/react-slot": "^1.2.4", + "@streamdown/code": "^1.1.1", + "@tabler/icons-react": "^3.41.1", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query": "^5.100.6", + "@tanstack/react-virtual": "^3.13.24", + "@vscode/codicons": "^0.0.45", + "@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", + "cmdk": "^1.1.1", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.2", + "ignore": "^7.0.5", + "katex": "^0.16.45", + "leva": "^0.10.1", + "motion": "^12.38.0", + "nanostores": "^1.3.0", + "node-pty": "1.1.0", + "radix-ui": "^1.4.3", + "react": "^19.2.5", + "react-arborist": "^3.5.0", + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2", + "react-shiki": "^0.9.3", + "remark-math": "^6.0.0", + "shiki": "^4.0.2", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", + "tw-shimmer": "^0.4.11", + "unicode-animations": "^1.0.3", + "unified": "^11.0.5", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3", + "web-haptics": "^0.0.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.3.2", + "@types/hast": "^3.0.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "electron": "^40.9.3", + "electron-builder": "^26.8.1", + "eslint": "^9.39.4", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-compiler": "^19.1.0-rc.2", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^16.5.0", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", + "vite": "^8.0.10", + "vitest": "^4.1.5", + "wait-on": "^9.0.5" + } + }, + "../shared": { + "name": "@hermes/shared", + "version": "0.0.0", + "devDependencies": { + "typescript": "^6.0.3" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@assistant-ui/core": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@assistant-ui/core/-/core-0.1.17.tgz", + "integrity": "sha512-IWIP98UVQ9W+oF0yz8XqFRtaX8HtozWVUWt6D/BSV6cyKwLfJ8niHtLG74bSnllTnGcreU2El3GR/tIodR1XuA==", + "license": "MIT", + "dependencies": { + "assistant-stream": "^0.3.12", + "nanoid": "^5.1.9" + }, + "peerDependencies": { + "@assistant-ui/store": "^0.2.9", + "@assistant-ui/tap": "^0.5.10", + "@types/react": "*", + "assistant-cloud": "^0.1.27", + "react": "^18 || ^19", + "zustand": "^5.0.11" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "assistant-cloud": { + "optional": true + }, + "react": { + "optional": true + }, + "zustand": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react": { + "version": "0.12.28", + "resolved": "https://registry.npmjs.org/@assistant-ui/react/-/react-0.12.28.tgz", + "integrity": "sha512-czjpexLK1lKnNDNM1YMJi8SufeKUWBICqiVUtiHMV+86PYGCwJykOZKkchI8MVbSQ62xZ8A1LfPO5W2IDjed3A==", + "license": "MIT", + "dependencies": { + "@assistant-ui/core": "^0.1.17", + "@assistant-ui/store": "^0.2.9", + "@assistant-ui/tap": "^0.5.10", + "@radix-ui/primitive": "^1.1.3", + "@radix-ui/react-compose-refs": "^1.1.2", + "@radix-ui/react-context": "^1.1.3", + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-use-callback-ref": "^1.1.1", + "@radix-ui/react-use-escape-keydown": "^1.1.1", + "assistant-cloud": "^0.1.27", + "assistant-stream": "^0.3.12", + "nanoid": "^5.1.9", + "radix-ui": "^1.4.3", + "react-textarea-autosize": "^8.5.9", + "zod": "^4.3.6", + "zustand": "^5.0.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react-streamdown": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@assistant-ui/react-streamdown/-/react-streamdown-0.1.11.tgz", + "integrity": "sha512-9y+89ZxotYSt81hChSVjK2kwUYRKq7UW/r5qoqZTpcb7119gc0NOj0dx9xxuyXE2QfR6EY8rW6yBz3g+Y7RrhQ==", + "license": "MIT", + "dependencies": { + "rehype-harden": "^1.1.8", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "streamdown": "^2.5.0" + }, + "peerDependencies": { + "@assistant-ui/react": "^0.12.26", + "@streamdown/cjk": "^1.0.0", + "@streamdown/code": "^1.0.0", + "@streamdown/math": "^1.0.0", + "@streamdown/mermaid": "^1.0.0", + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@streamdown/cjk": { + "optional": true + }, + "@streamdown/code": { + "optional": true + }, + "@streamdown/math": { + "optional": true + }, + "@streamdown/mermaid": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/store": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.10.tgz", + "integrity": "sha512-cgbSFIv0Ovu6yls4GQy7brLVx6qwUyLTf1Ki/lkj3UFJrO6oktxstosWvQBwk5mNgH6t3DOIrGSBDJSKRfCW5Q==", + "license": "MIT", + "dependencies": { + "use-effect-event": "^2.0.3" + }, + "peerDependencies": { + "@assistant-ui/tap": "^0.5.11", + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/tap": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@assistant-ui/tap/-/tap-0.5.11.tgz", + "integrity": "sha512-wsEp6mn6BOQnP56OksWHarIQiMeCDcTzEiAORTUq0yxWa/co6a06UowFe6zZS6WQ56EQ3w02bfSBFrGnsrIv5A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@audiowave/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@audiowave/core/-/core-0.3.1.tgz", + "integrity": "sha512-KtC2MTWKp6Orkedty3I8IklVBVQ2IFaFWDJ1cz+UsACpX2x1gINwZGTRZT7bw/dx8KazNSMuVK5lm1jL67KQkQ==", + "license": "MIT" + }, + "node_modules/@audiowave/react": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@audiowave/react/-/react-0.6.2.tgz", + "integrity": "sha512-hajG2Iv3mVxived9wXad8L0ZQF+HmYnB3IrfOkIdkTv4RxOJDXwFWMAd0zb7ZU1Qz0IEYZXCbASFWyuxEQ7PAw==", + "license": "MIT", + "dependencies": { + "@audiowave/core": "0.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", + "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.29.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "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.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@chenglou/pretext": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.6.tgz", + "integrity": "sha512-U10s4tFeyu3oVHfXuNWwZSKqHXefhaigpcBkGj60qQFRJ+yUoQ+ez3cGJelP7BWDAB58HCgjcTSmOcg+77afBQ==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hermes/shared": { + "resolved": "../shared", + "link": true + }, + "node_modules/@humanfs/core": { + "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.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.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", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.1" + } + }, + "node_modules/@nanostores/react": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.1.0.tgz", + "integrity": "sha512-MbH35fjhcf7LAubYX5vhOChYUfTLzNLqH/mBGLVsHkcvjy0F8crO1WQwdmQ2xKbAmtpalDa2zBt3Hlg5kqr8iw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "nanostores": "^1.2.0", + "react": ">=18.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nous-research/ui": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.2.tgz", + "integrity": "sha512-iuav0o8UCpUDkleEF2JTNpC9SMwJxrsOL9bewTS+7eUcwHSD5Bk4Al6XX66ceIXyEWRkVDgODmpeuGOA5W2yCw==", + "license": "MIT", + "dependencies": { + "@nanostores/react": "^1.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "nanostores": "^1.0.1", + "sanitize-html": "^2.16.0", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0", + "unicode-animations": "^1.0.3" + }, + "peerDependencies": { + "@observablehq/plot": "^0.6.17", + "@react-three/fiber": "^9.4.0", + "gsap": "^3.13.0", + "leva": "^0.10.1", + "motion": "^12.38.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "three": "^0.180.0" + }, + "peerDependenciesMeta": { + "@observablehq/plot": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "gsap": { + "optional": true + }, + "leva": { + "optional": true + }, + "three": { + "optional": true + } + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-dnd/asap": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.1.0.tgz", + "integrity": "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.1.0", + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.1.0.tgz", + "integrity": "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.1.0.tgz", + "integrity": "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.1.0.tgz", + "integrity": "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.1.0.tgz", + "integrity": "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.1.0.tgz", + "integrity": "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.1.0.tgz", + "integrity": "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@stitches/react": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz", + "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.3.0" + } + }, + "node_modules/@streamdown/code": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz", + "integrity": "sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==", + "license": "Apache-2.0", + "dependencies": { + "shiki": "^3.19.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@streamdown/code/node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tabler/icons": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz", + "integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz", + "integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.44.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz", + "integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz", + "integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vscode/codicons": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45.tgz", + "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==", + "license": "CC-BY-4.0" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.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/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.8.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assistant-cloud": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz", + "integrity": "sha512-BGfVnx7YFN5xtB/kbrgGxRI0TfSWq4yxB3MwYn6RDPlv4JvdtPupvDC1Y6An0EhAe42Z0AYtSmDSsR6p6eeBng==", + "license": "MIT", + "dependencies": { + "assistant-stream": "^0.3.12" + } + }, + "node_modules/assistant-stream": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.3.14.tgz", + "integrity": "sha512-LWJt+6cjukoEKaN3LHwx40QbnODnoMmGCPkF4Tjg3fwTjgUTWsYnNJ5H2dnRmJFbxVgKTNMdJHjkCIOSemE2tg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "nanoid": "^5.1.11", + "secure-json-parse": "^4.1.0" + }, + "peerDependencies": { + "ioredis": "^5.10.1", + "redis": "^5.12.1" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + }, + "redis": { + "optional": true + } + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "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": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "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" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.33.4", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", + "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "40.10.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.10.1.tgz", + "integrity": "sha512-F2Iy16vLBV4NodRVf2dGRESUW03AdFlCmOjaF1fhc3FupmTwEGWmKsYXvORkUYLPBSrKP7r08e8PHiImoe8woQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.8.1", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.359", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.359.tgz", + "integrity": "sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz", + "integrity": "sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.9.0.tgz", + "integrity": "sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.58.2", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-compiler": { + "version": "19.1.0-rc.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz", + "integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "hermes-parser": "^0.25.1", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + }, + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-plugin-react-compiler/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "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": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "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 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend-shallow/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-selector": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz", + "integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/framer-motion": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.39.0.tgz", + "integrity": "sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.39.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "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", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joi": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/leva": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", + "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-portal": "^1.1.4", + "@radix-ui/react-tooltip": "^1.1.8", + "@stitches/react": "^1.2.8", + "@use-gesture/react": "^10.2.5", + "colord": "^2.9.2", + "dequal": "^2.0.2", + "merge-value": "^1.0.0", + "react-colorful": "^5.5.1", + "react-dropzone": "^12.0.0", + "v8n": "^1.3.3", + "zustand": "^3.6.9" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "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", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "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" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "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-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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz", + "integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==", + "license": "MIT", + "dependencies": { + "get-value": "^2.0.6", + "is-extendable": "^1.0.0", + "mixin-deep": "^1.2.0", + "set-value": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mermaid": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "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.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/motion": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.39.0.tgz", + "integrity": "sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.39.0.tgz", + "integrity": "sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/nanostores": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", + "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-abi": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", + "integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-gyp": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-arborist": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.7.0.tgz", + "integrity": "sha512-gh2SoO0eXQVSP6zxXMGqFeXF+l2uabDGBVn0+RKqy/s7mrG5xGnfM5mhyB67cMVobC3vWYLqe6HGh7ZEZadW/w==", + "license": "MIT", + "dependencies": { + "react-dnd": "^14.0.3", + "react-dnd-html5-backend": "^14.0.3", + "react-window": "^1.8.11", + "redux": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": ">= 16.14", + "react-dom": ">= 16.14" + } + }, + "node_modules/react-colorful": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz", + "integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-dnd": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "license": "MIT", + "dependencies": { + "dnd-core": "14.0.1" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-dropzone": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.1.0.tgz", + "integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.5.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-shiki": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.3.tgz", + "integrity": "sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "dequal": "^2.0.3", + "hast-util-to-jsx-runtime": "^2.3.6", + "shiki": "^4.0.0", + "unist-util-visit": "^5.1.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "@types/react-dom": ">=16.8.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rehype-harden": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/rehype-harden/-/rehype-harden-1.1.8.tgz", + "integrity": "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==", + "license": "MIT", + "dependencies": { + "unist-util-visit": "^5.0.0" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remend": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.0.tgz", + "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sanitize-html": { + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz", + "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^10.1.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "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/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/set-value/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "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", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shiki": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.1.0.tgz", + "integrity": "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.1.0", + "@shikijs/engine-javascript": "4.1.0", + "@shikijs/engine-oniguruma": "4.1.0", + "@shikijs/langs": "4.1.0", + "@shikijs/themes": "4.1.0", + "@shikijs/types": "4.1.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamdown": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.5.0.tgz", + "integrity": "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1", + "hast-util-to-jsx-runtime": "^2.3.6", + "html-url-attributes": "^3.0.1", + "marked": "^17.0.1", + "mermaid": "^11.12.2", + "rehype-harden": "^1.1.8", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remend": "1.3.0", + "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-api-utils": { + "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": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/tw-shimmer": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz", + "integrity": "sha512-pTpGJzp3xaCPO87WeHETngmZHJYvygiSTt4jqzh2oR3DWBoeudi/ANB304zks9+Cm2vQ1ai3w9fetviYdqY8HQ==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=4.0.0-0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-animations": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz", + "integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "unicode-animations": "^1.0.1" + }, + "bin": { + "unicode-animations": "scripts/demo.cjs" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-effect-event": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/use-effect-event/-/use-effect-event-2.0.3.tgz", + "integrity": "sha512-fz1en+z3fYXCXx3nMB8hXDMuygBltifNKZq29zDx+xNJ+1vEs6oJlYd9sK31vxJ0YI534VUsHEBY0k2BATsmBQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.3 || ^19.0.0-0" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/v8n": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz", + "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==", + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wait-on": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/web-haptics": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz", + "integrity": "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz", + "integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.24.4" + } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "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/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 000000000..7056ec736 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,232 @@ +{ + "name": "hermes", + "productName": "Hermes", + "private": true, + "version": "0.15.1", + "description": "Native desktop shell for Hermes Agent.", + "author": "Nous Research", + "type": "module", + "main": "electron/main.cjs", + "scripts": { + "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", + "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", + "dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174", + "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", + "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "start": "npm run build && electron .", + "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build", + "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", + "pack": "npm run build && npm run builder -- --dir", + "dist": "npm run build && npm run builder", + "dist:mac": "npm run build && npm run builder -- --mac", + "dist:mac:dmg": "npm run build && npm run builder -- --mac dmg", + "dist:mac:zip": "npm run build && npm run builder -- --mac zip", + "dist:win": "npm run build && npm run builder -- --win", + "dist:win:msi": "npm run build && npm run builder -- --win msi", + "dist:win:nsis": "npm run build && npm run builder -- --win nsis", + "dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm", + "test:desktop": "node scripts/test-desktop.mjs", + "test:desktop:all": "node scripts/test-desktop.mjs all", + "test:desktop:dmg": "node scripts/test-desktop.mjs dmg", + "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", + "test:desktop:existing": "node scripts/test-desktop.mjs existing", + "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs", + "type-check": "tsc -b", + "lint": "eslint src/ electron/", + "lint:fix": "eslint src/ electron/ --fix", + "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'", + "fix": "npm run lint:fix && npm run fmt", + "test:ui": "vitest run --environment jsdom", + "preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174" + }, + "dependencies": { + "@assistant-ui/react": "^0.12.28", + "@assistant-ui/react-streamdown": "^0.1.11", + "@audiowave/react": "^0.6.2", + "@chenglou/pretext": "^0.0.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hermes/shared": "file:../shared", + "@nanostores/react": "^1.1.0", + "@nous-research/ui": "^0.13.0", + "@radix-ui/react-slot": "^1.2.4", + "@streamdown/code": "^1.1.1", + "@tabler/icons-react": "^3.41.1", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query": "^5.100.6", + "@tanstack/react-virtual": "^3.13.24", + "@vscode/codicons": "^0.0.45", + "@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", + "cmdk": "^1.1.1", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.2", + "ignore": "^7.0.5", + "katex": "^0.16.45", + "leva": "^0.10.1", + "motion": "^12.38.0", + "nanostores": "^1.3.0", + "node-pty": "1.1.0", + "radix-ui": "^1.4.3", + "react": "^19.2.5", + "react-arborist": "^3.5.0", + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2", + "react-shiki": "^0.9.3", + "remark-math": "^6.0.0", + "shiki": "^4.0.2", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", + "tw-shimmer": "^0.4.11", + "unicode-animations": "^1.0.3", + "unified": "^11.0.5", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3", + "web-haptics": "^0.0.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.3.2", + "@types/hast": "^3.0.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "electron": "^40.9.3", + "electron-builder": "^26.8.1", + "eslint": "^9.39.4", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-compiler": "^19.1.0-rc.2", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^16.5.0", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", + "rcedit": "^5.0.2", + "typescript": "^6.0.3", + "vite": "^8.0.10", + "vitest": "^4.1.5", + "wait-on": "^9.0.5" + }, + "build": { + "electronVersion": "40.9.3", + "appId": "com.nousresearch.hermes", + "productName": "Hermes", + "executableName": "Hermes", + "artifactName": "Hermes-${version}-${os}-${arch}.${ext}", + "icon": "assets/icon", + "directories": { + "output": "release" + }, + "files": [ + "dist/**", + "assets/**", + "electron/**", + "public/**", + "package.json" + ], + "beforeBuild": "scripts/before-build.cjs", + "afterPack": "scripts/after-pack.cjs", + "extraResources": [ + { + "from": "build/install-stamp.json", + "to": "install-stamp.json" + }, + { + "from": "build/native-deps", + "to": "native-deps" + }, + { + "from": "assets/icon.ico", + "to": "icon.ico" + } + ], + "asar": true, + "afterSign": "scripts/notarize.cjs", + "asarUnpack": [ + "**/*.node", + "**/prebuilds/**" + ], + "mac": { + "category": "public.app-category.developer-tools", + "entitlements": "electron/entitlements.mac.plist", + "entitlementsInherit": "electron/entitlements.mac.inherit.plist", + "extendInfo": { + "CFBundleDisplayName": "Hermes", + "CFBundleExecutable": "Hermes", + "CFBundleName": "Hermes", + "NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.", + "NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations." + }, + "gatekeeperAssess": false, + "hardenedRuntime": true, + "target": [ + "dmg", + "zip" + ] + }, + "dmg": { + "title": "Install Hermes", + "backgroundColor": "#f5f5f7", + "iconSize": 96, + "window": { + "width": 560, + "height": 360 + }, + "contents": [ + { + "x": 160, + "y": 170, + "type": "file" + }, + { + "x": 400, + "y": 170, + "type": "link", + "path": "/Applications" + } + ] + }, + "win": { + "legalTrademarks": "Hermes", + "target": [ + "nsis", + "msi" + ], + "signAndEditExecutable": false + }, + "linux": { + "category": "Development", + "maintainer": "Nous Research ", + "synopsis": "Native desktop shell for Hermes Agent.", + "target": [ + "AppImage", + "deb", + "rpm" + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "perMachine": false, + "shortcutName": "Hermes", + "uninstallDisplayName": "Hermes", + "warningsAsErrors": false + } + } +} diff --git a/apps/desktop/preview-demo.html b/apps/desktop/preview-demo.html new file mode 100644 index 000000000..05bfb69ee --- /dev/null +++ b/apps/desktop/preview-demo.html @@ -0,0 +1,65 @@ + + + + + +Preview Demo + + + +
+

preview-demo.html

+

Tiny standalone HTML artifact — no server, no build step.

+

Open directly in a browser via file://.

+

+
+ + + diff --git a/apps/desktop/public/apple-touch-icon.png b/apps/desktop/public/apple-touch-icon.png new file mode 100644 index 000000000..ed92319bb Binary files /dev/null and b/apps/desktop/public/apple-touch-icon.png differ diff --git a/apps/desktop/public/ds-assets/filler-bg0.jpg b/apps/desktop/public/ds-assets/filler-bg0.jpg new file mode 100644 index 000000000..490969417 Binary files /dev/null and b/apps/desktop/public/ds-assets/filler-bg0.jpg differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-0.png b/apps/desktop/public/hermes-frames/hermes-frame-0.png new file mode 100644 index 000000000..4c3880c25 Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-0.png differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-1.png b/apps/desktop/public/hermes-frames/hermes-frame-1.png new file mode 100644 index 000000000..37741ae03 Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-1.png differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-2.png b/apps/desktop/public/hermes-frames/hermes-frame-2.png new file mode 100644 index 000000000..bd3050bff Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-2.png differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-3.png b/apps/desktop/public/hermes-frames/hermes-frame-3.png new file mode 100644 index 000000000..1430737ca Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-3.png differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-4.png b/apps/desktop/public/hermes-frames/hermes-frame-4.png new file mode 100644 index 000000000..2173a3347 Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-4.png differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-5.png b/apps/desktop/public/hermes-frames/hermes-frame-5.png new file mode 100644 index 000000000..6c9cd03f7 Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-5.png differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-6.png b/apps/desktop/public/hermes-frames/hermes-frame-6.png new file mode 100644 index 000000000..e15046250 Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-6.png differ diff --git a/apps/desktop/public/hermes-frames/hermes-frame-7.png b/apps/desktop/public/hermes-frames/hermes-frame-7.png new file mode 100644 index 000000000..b4c0e3f5d Binary files /dev/null and b/apps/desktop/public/hermes-frames/hermes-frame-7.png differ diff --git a/apps/desktop/public/hermes-sprite.png b/apps/desktop/public/hermes-sprite.png new file mode 100644 index 000000000..94c29c463 Binary files /dev/null and b/apps/desktop/public/hermes-sprite.png differ diff --git a/apps/desktop/public/hermes.png b/apps/desktop/public/hermes.png new file mode 100644 index 000000000..83a8969d6 Binary files /dev/null and b/apps/desktop/public/hermes.png differ diff --git a/apps/desktop/scripts/after-pack.cjs b/apps/desktop/scripts/after-pack.cjs new file mode 100644 index 000000000..f81262d28 --- /dev/null +++ b/apps/desktop/scripts/after-pack.cjs @@ -0,0 +1,41 @@ +/** + * after-pack.cjs — electron-builder afterPack hook. + * + * Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via + * rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build + * — first install, `hermes desktop`, the installer's --update rebuild, and a + * dev's manual `npm run pack` — so the branded exe can never silently revert + * to the stock "Electron" icon/name (the bug when the stamp lived only in + * install.ps1, which the update path doesn't use). + * + * Windows-only: rcedit edits PE resources, irrelevant on macOS/Linux where the + * app identity comes from the bundle Info.plist / desktop entry. Best-effort: + * a stamp failure must never fail an otherwise-good build (worst case is the + * stock icon, not a broken app), so we log and resolve rather than throw. + * + * electron-builder passes a context with: + * - electronPlatformName: 'win32' | 'darwin' | 'linux' + * - appOutDir: the unpacked app directory for this target + * - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes') + */ + +const path = require('node:path') + +const { stampExeIdentity } = require('./set-exe-identity.cjs') + +exports.default = async function afterPack(context) { + if (context.electronPlatformName !== 'win32') { + return + } + + const productName = context.packager?.appInfo?.productFilename || 'Hermes' + const exe = path.join(context.appOutDir, `${productName}.exe`) + const desktopRoot = path.resolve(__dirname, '..') + + try { + await stampExeIdentity(exe, desktopRoot) + } catch (err) { + // Never fail the build over a cosmetic stamp. + console.warn(`[after-pack] exe identity stamp failed (${err.message}); Hermes.exe keeps the stock Electron icon`) + } +} diff --git a/apps/desktop/scripts/assert-root-install.cjs b/apps/desktop/scripts/assert-root-install.cjs new file mode 100644 index 000000000..26433ca9b --- /dev/null +++ b/apps/desktop/scripts/assert-root-install.cjs @@ -0,0 +1,13 @@ +"use strict" + +const fs = require("fs") +const path = require("path") + +const root = path.resolve(__dirname, "..", "..", "..") + +try { + fs.accessSync(path.join(root, "node_modules", "vite", "package.json")) +} catch { + console.error(`Run from repo root: cd ${root} && npm ci`) + process.exit(1) +} diff --git a/apps/desktop/scripts/before-build.cjs b/apps/desktop/scripts/before-build.cjs new file mode 100644 index 000000000..673aca380 --- /dev/null +++ b/apps/desktop/scripts/before-build.cjs @@ -0,0 +1,11 @@ +/** + * Desktop bundles ship precompiled renderer assets. Returning false here tells + * electron-builder to skip the node_modules collector/install step, which + * avoids workspace dependency graph explosions and keeps packaging + * deterministic across environments. The Hermes Agent Python payload is no + * longer bundled; the Electron app fetches it at first launch via + * `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`. + */ +module.exports = async function beforeBuild() { + return false +} diff --git a/apps/desktop/scripts/click-session.mjs b/apps/desktop/scripts/click-session.mjs new file mode 100644 index 000000000..77983f51d --- /dev/null +++ b/apps/desktop/scripts/click-session.mjs @@ -0,0 +1,51 @@ +// Click on a session by partial title match. +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (method, params = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method, params })) + }) + +const title = process.argv[2] || 'Phaser particle' +const r = await send('Runtime.evaluate', { + expression: ` + (() => { + const titleMatch = ${JSON.stringify(title)} + const all = document.querySelectorAll('button, a, div[role="button"]') + const found = [...all].find(el => (el.textContent || '').includes(titleMatch)) + if (!found) return JSON.stringify({ found: false, tried: titleMatch }) + found.scrollIntoView() + found.click() + return JSON.stringify({ found: true, tag: found.tagName, text: (found.textContent || '').slice(0, 80) }) + })() + `, + returnByValue: true +}) +console.log('click raw:', JSON.stringify(r, null, 2)) +await new Promise(r => setTimeout(r, 3000)) + +const status = await send('Runtime.evaluate', { + expression: `JSON.stringify({ + url: location.href, + hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'), + threadMessages: document.querySelectorAll('[data-slot="aui_message"]').length, + bodyTextSnippet: document.body.innerText.slice(0, 500), + title: document.title + })`, + returnByValue: true +}) +console.log('after click:', status.result.value) +ws.close() diff --git a/apps/desktop/scripts/dev-no-hmr.mjs b/apps/desktop/scripts/dev-no-hmr.mjs new file mode 100644 index 000000000..9647e9738 --- /dev/null +++ b/apps/desktop/scripts/dev-no-hmr.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// Launch the desktop renderer with HMR disabled so the React Fast Refresh +// preamble path is skipped. This sidesteps a current Vite 8 / plugin-react 6 +// bug where the preamble script is not injected into index.html → renderer +// throws "$RefreshReg$ is not defined" on every TSX module → React tree +// never mounts. +// +// We're not trying to use HMR while profiling typing lag anyway. Hermes desktop +// boots, you type, profiler measures. HMR off is fine. +// +// Usage: node apps/desktop/scripts/dev-no-hmr.mjs +// (then in another shell, run electron --remote-debugging-port=9222 .) + +import { createServer } from 'vite' + +const server = await createServer({ + configFile: new URL('../vite.config.ts', import.meta.url).pathname, + root: new URL('../', import.meta.url).pathname, + server: { hmr: false, host: '127.0.0.1', port: 5174, strictPort: true } +}) +await server.listen() +server.printUrls() diff --git a/apps/desktop/scripts/diag-jump.mjs b/apps/desktop/scripts/diag-jump.mjs new file mode 100644 index 000000000..f02183cc1 --- /dev/null +++ b/apps/desktop/scripts/diag-jump.mjs @@ -0,0 +1,115 @@ +// Wrap the thread scroller's properties and observe pin/scroll/RO events +// in real time during a submit, then print the timeline. +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (m, p = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method: m, params: p })) + }) +const evalP = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text) + return r.result.result.value +} + +await evalP(`(() => { + const v = document.querySelector('[data-slot="aui_thread-viewport"]') + if (v) v.scrollTop = v.scrollHeight +})()`) +await new Promise(r => setTimeout(r, 300)) + +await evalP(`(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + el.focus() + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false) + window.getSelection().removeAllRanges(); window.getSelection().addRange(r) +})()`) + +const text = 'short follow-up' +for (const c of text) { + await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 10)) +} +await new Promise(r => setTimeout(r, 300)) + +// Hook into the viewport scrollTop setter + scroll + RO so we see every event +await evalP(`(() => { + const v = document.querySelector('[data-slot="aui_thread-viewport"]') + const events = [] + window.__threadEvents = events + const t0 = performance.now() + const push = (kind, detail) => events.push({ t: performance.now() - t0, kind, ...detail }) + + // intercept scrollTop writes + const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop') + Object.defineProperty(v, 'scrollTop', { + get() { return desc.get.call(this) }, + set(val) { + push('scrollTop=', { val, fromScrollHeight: this.scrollHeight, stackTop: (new Error()).stack.split('\\n').slice(2, 5).map(s => s.trim()).join(' | ') }) + desc.set.call(this, val) + }, + configurable: true + }) + + // scroll event + v.addEventListener('scroll', () => { + push('scroll', { scrollTop: v.scrollTop, scrollHeight: v.scrollHeight }) + }, { passive: true, capture: true }) + + // RO on the viewport itself + const ro = new ResizeObserver((entries) => { + for (const e of entries) { + push('RO', { target: e.target.getAttribute('data-slot') || e.target.tagName, h: e.contentRect.height }) + } + }) + ro.observe(v) + if (v.firstElementChild) ro.observe(v.firstElementChild) + + // mutationobserver on the viewport + const mo = new MutationObserver((muts) => { + push('mut', { count: muts.length, added: muts.reduce((s, m) => s + m.addedNodes.length, 0), removed: muts.reduce((s, m) => s + m.removedNodes.length, 0) }) + }) + mo.observe(v, { childList: true, subtree: true, characterData: true }) + + window.__teardown = () => { ro.disconnect(); mo.disconnect() } + return true +})()`) + +// fire Enter +await send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r' +}) +await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' }) + +await new Promise(r => setTimeout(r, 1200)) + +const events = JSON.parse(await evalP(`JSON.stringify(window.__threadEvents || [])`)) +console.log(`\n${events.length} events:`) +for (const e of events) { + const t = String(e.t.toFixed(0)).padStart(5) + const { kind, t: _t, ...rest } = e + console.log(` ${t}ms ${kind.padEnd(12)} ${JSON.stringify(rest)}`) +} + +await evalP(`window.__teardown?.()`) +// Cancel running agent +await evalP(`(() => { + for (const b of document.querySelectorAll('button')) { + if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' } + } +})()`) + +ws.close() diff --git a/apps/desktop/scripts/eval.mjs b/apps/desktop/scripts/eval.mjs new file mode 100644 index 000000000..b7336315d --- /dev/null +++ b/apps/desktop/scripts/eval.mjs @@ -0,0 +1,21 @@ +// Simple eval helper — runs an expression and returns the result.value. +const targets = await (await fetch('http://127.0.0.1:9222/json')).json() +const t = targets.find((t) => t.url.includes('5174')) +const ws = new WebSocket(t.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', (ev) => { + const m = JSON.parse(ev.data) + if (pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } +}) +await new Promise((r) => ws.addEventListener('open', r)) +const send = (method, params) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) }) + +const expr = process.argv[2] || '1+1' +const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) +if (r.result.exceptionDetails) { + console.error('EXCEPTION:', r.result.exceptionDetails.exception?.description) +} else { + console.log(JSON.stringify(r.result.result.value, null, 2)) +} +ws.close() diff --git a/apps/desktop/scripts/leak-typing.mjs b/apps/desktop/scripts/leak-typing.mjs new file mode 100644 index 000000000..d43a84782 --- /dev/null +++ b/apps/desktop/scripts/leak-typing.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +// Leak-detection harness — measure detached DOM, listener count, and FiberNode +// growth as a function of keystrokes typed. +// +// Workflow: +// 1. Open session, focus composer +// 2. forceGC; capture baseline counts +// 3. Repeat N rounds: type M chars, forceGC, capture counts, clear composer +// 4. Print growth-per-round table +// +// Usage: +// node apps/desktop/scripts/leak-typing.mjs [--rounds=6] [--chars=200] [--cps=40] [--port=9222] + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) +const PORT = Number(args.port ?? 9222) +const ROUNDS = Number(args.rounds ?? 6) +const CHARS = Number(args.chars ?? 200) +const CPS = Number(args.cps ?? 40) + +const log = (...m) => console.log('[leak]', ...m) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + return list.find(t => t.type === 'page' && t.url.startsWith('http')) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + const events = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + on(method, h) { + if (!events.has(method)) events.set(method, []) + events.get(method).push(h) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } else if (m.method) { + ;(events.get(m.method) ?? []).forEach(h => h(m.params)) + } + }) + }) +} + +async function evalInPage(cdp, expr) { + const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function forceGCAndSettle(cdp) { + for (let i = 0; i < 3; i++) { + await cdp.send('HeapProfiler.collectGarbage') + await new Promise(r => setTimeout(r, 60)) + } +} + +async function focusComposer(cdp) { + return await evalInPage( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return false + el.focus() + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + return true + })()` + ) +} + +async function clearComposer(cdp) { + await evalInPage( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return false + // Clear via the same path as the composer's clear flow: + // dispatch a single Backspace until empty would be N round-trips; quicker + // to directly assign empty text and fire input. + el.innerHTML = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + el.focus() + return el.innerText.length === 0 + })()` + ) +} + +async function snapshotCounts(cdp) { + // Counts via Runtime.evaluate using internal V8 counters where possible. + // For DOM stats we directly query the document. + // Performance metrics include JSHeapUsedSize, Nodes, JSEventListeners, etc. + const { metrics } = await cdp.send('Performance.getMetrics') + const byName = Object.fromEntries(metrics.map(m => [m.name, m.value])) + // Total nodes in document + const docNodes = await evalInPage( + cdp, + `document.getElementsByTagName('*').length + document.querySelectorAll('*').length / 2` + ) + return { + heapUsedMB: (byName.JSHeapUsedSize / 1024 / 1024) || 0, + heapTotalMB: (byName.JSHeapTotalSize / 1024 / 1024) || 0, + nodes: byName.Nodes || 0, + jsListeners: byName.JSEventListeners || 0, + docNodes, + layoutCount: byName.LayoutCount || 0, + recalcStyleCount: byName.RecalcStyleCount || 0, + fps: byName.FramesPerSecond || 0 + } +} + +async function typeChars(cdp, text, cps) { + const intervalMs = Math.max(1, Math.round(1000 / cps)) + const start = Date.now() + for (let i = 0; i < text.length; i++) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] }) + const expected = start + (i + 1) * intervalMs + const wait = expected - Date.now() + if (wait > 0) await new Promise(r => setTimeout(r, wait)) + } +} + +const lorem = + 'the quick brown fox jumps over the lazy dog while the agent thinks really hard about why typing into this composer feels like wading through molasses on a hot afternoon ' +function genText(n) { + let s = '' + while (s.length < n) s += lorem + return s.slice(0, n) +} + +async function main() { + log(`port ${PORT} · ${ROUNDS} rounds × ${CHARS} chars @ ${CPS} cps`) + const tgt = await pickRenderer() + log(`target ${tgt.url}`) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + await cdp.send('Performance.enable') + await cdp.send('DOM.enable') + + const focused = await focusComposer(cdp) + if (!focused) { + console.error('composer not focusable') + process.exit(2) + } + + await forceGCAndSettle(cdp) + const baseline = await snapshotCounts(cdp) + log('baseline:', JSON.stringify(baseline)) + + const text = genText(CHARS) + const history = [{ round: 0, ...baseline, charsTyped: 0 }] + + for (let r = 1; r <= ROUNDS; r++) { + await typeChars(cdp, text, CPS) + await new Promise(res => setTimeout(res, 200)) + await clearComposer(cdp) + await forceGCAndSettle(cdp) + const snap = await snapshotCounts(cdp) + snap.charsTyped = r * CHARS + snap.round = r + history.push(snap) + log( + `round ${r}: heap=${snap.heapUsedMB.toFixed(1)}MB ` + + `nodes=${snap.nodes} listeners=${snap.jsListeners} ` + + `domNodes=${Math.round(snap.docNodes)} ` + + `layoutCount=${snap.layoutCount} ` + + `Δheap=+${(snap.heapUsedMB - baseline.heapUsedMB).toFixed(2)}MB ` + + `Δnodes=+${snap.nodes - baseline.nodes} ` + + `Δlisteners=+${snap.jsListeners - baseline.jsListeners}` + ) + } + + console.log('\n=== GROWTH PER ROUND (averaged over last 5 rounds) ===') + const tail = history.slice(-5) + const first = tail[0] + const last = tail[tail.length - 1] + const rounds = last.round - first.round + const cells = ['heapUsedMB', 'nodes', 'jsListeners', 'docNodes', 'layoutCount'] + for (const c of cells) { + const delta = last[c] - first[c] + const per = delta / Math.max(1, rounds) + const perChar = delta / Math.max(1, rounds * CHARS) + console.log(` ${c.padEnd(16)} Δtotal=${delta.toFixed(2).padStart(10)} /round=${per.toFixed(2).padStart(8)} /char=${perChar.toFixed(4).padStart(8)}`) + } + + writeFileSync('/tmp/hermes-leak-history.json', JSON.stringify(history, null, 2)) + log('wrote /tmp/hermes-leak-history.json') + cdp.close() +} + +main().catch(e => { + console.error('[leak] fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/measure-jump.mjs b/apps/desktop/scripts/measure-jump.mjs new file mode 100644 index 000000000..1b5d88f72 --- /dev/null +++ b/apps/desktop/scripts/measure-jump.mjs @@ -0,0 +1,108 @@ +// Measure scroll position before and after Enter on a long thread. +// The user's complaint: pressing Enter to submit makes the view "jump up". +// +// Steps: +// 1. Scroll to the bottom of the thread +// 2. Type a short message +// 3. Record scroll position +// 4. Hit Enter +// 5. Record scroll position every 10ms for 1.5s after Enter +// 6. Report deltas +// +// Usage: node apps/desktop/scripts/measure-jump.mjs + +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (m, p = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method: m, params: p })) + }) +const evalP = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text) + return r.result.result.value +} + +// Scroll to bottom +await evalP(`(() => { + const v = document.querySelector('[data-slot="aui_thread-viewport"]') + if (v) v.scrollTop = v.scrollHeight +})()`) +await new Promise(r => setTimeout(r, 300)) + +// Focus composer and type +await evalP(`(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + el.focus() + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false) + window.getSelection().removeAllRanges(); window.getSelection().addRange(r) +})()`) + +const text = 'short follow-up message' +for (const c of text) { + await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 10)) +} +await new Promise(r => setTimeout(r, 300)) + +// Set up sampling — sample scroll position every animation frame +await evalP(`(() => { + const v = document.querySelector('[data-slot="aui_thread-viewport"]') + window.__jumpSamples = [] + window.__jumpStart = performance.now() + const tick = () => { + if (!v) return + window.__jumpSamples.push({ + t: performance.now() - window.__jumpStart, + scrollTop: v.scrollTop, + scrollHeight: v.scrollHeight, + clientHeight: v.clientHeight, + distFromBottom: v.scrollHeight - v.scrollTop - v.clientHeight + }) + if (performance.now() - window.__jumpStart < 2000) { + requestAnimationFrame(tick) + } + } + requestAnimationFrame(tick) +})()`) + +// Fire Enter +await send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r' +}) +await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' }) + +await new Promise(r => setTimeout(r, 2200)) + +const samples = JSON.parse(await evalP(`JSON.stringify(window.__jumpSamples || [])`)) +console.log(`\n${samples.length} samples over 2s`) +console.log(`\n t(ms) scrollTop scrollHeight clientHeight distFromBottom`) +let prev = null +for (const s of samples) { + const marker = prev && Math.abs(s.scrollTop - prev.scrollTop) > 5 ? ' ← jump' : '' + console.log(` ${String(s.t.toFixed(0)).padStart(5)} ${String(s.scrollTop).padStart(9)} ${String(s.scrollHeight).padStart(12)} ${String(s.clientHeight).padStart(12)} ${String(s.distFromBottom).padStart(14)}${marker}`) + prev = s +} + +// Cancel any running agent +await evalP(`(() => { + for (const b of document.querySelectorAll('button')) { + if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' } + } + return 'no-stop' +})()`).then(r => console.log('\ncancel:', r)) + +ws.close() diff --git a/apps/desktop/scripts/measure-latency.mjs b/apps/desktop/scripts/measure-latency.mjs new file mode 100644 index 000000000..c3f3da130 --- /dev/null +++ b/apps/desktop/scripts/measure-latency.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// Measure end-to-end keystroke→paint latency in the Electron renderer. +// +// For each synthetic keystroke we record: +// t0 = Input.dispatchKeyEvent send time +// t1 = first observed mutation of [data-slot="composer-rich-input"] childList/character data +// t2 = first requestAnimationFrame callback after t1 (proxy for next paint) +// +// We use Page.startScreencast briefly to also get frame-presentation timestamps; +// alternatively rely on rAF timing which is close enough for typing UX. +// +// Output: per-char latency histogram (min/p50/p95/p99/max) + samples > 16ms. +// +// Usage: +// node apps/desktop/scripts/measure-latency.mjs [--chars=100] [--cps=15] [--port=9222] + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) +const PORT = Number(args.port ?? 9222) +const CHARS = Number(args.chars ?? 100) +const CPS = Number(args.cps ?? 15) + +const log = (...m) => console.log('[latency]', ...m) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + return list.find(t => t.type === 'page' && t.url.startsWith('http')) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + const events = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + on(method, h) { + if (!events.has(method)) events.set(method, []) + events.get(method).push(h) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } else if (m.method) { + ;(events.get(m.method) ?? []).forEach(h => h(m.params)) + } + }) + }) +} + +async function evalInPage(cdp, expr) { + const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function main() { + const tgt = await pickRenderer() + log(`target ${tgt.url}`) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + + await evalInPage( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return false + el.focus() + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + window.__keypressTimings = [] + window.__pendingKey = null + // Observe the composer for content/text changes; record the time relative + // to the most recent simulated keypress timestamp set on window.__pendingKey. + const obs = new MutationObserver(() => { + const start = window.__pendingKey + if (start === null) return + const mutationT = performance.now() + window.__pendingKey = null + requestAnimationFrame(() => { + const paintT = performance.now() + window.__keypressTimings.push({ + start, mutationT, paintT, + mutationLatency: mutationT - start, + paintLatency: paintT - start + }) + }) + }) + obs.observe(el, { childList: true, subtree: true, characterData: true }) + window.__keystrokeObserver = obs + return true + })()` + ) + + const lorem = + 'the quick brown fox jumps over the lazy dog while typing into this composer feels like wading through molasses on a hot afternoon. ' + let text = '' + while (text.length < CHARS) text += lorem + text = text.slice(0, CHARS) + + const intervalMs = Math.max(1, Math.round(1000 / CPS)) + const start = Date.now() + for (let i = 0; i < text.length; i++) { + // Mark the keypress time inside the page so it's measured from the same clock. + await evalInPage(cdp, `window.__pendingKey = performance.now()`) + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] }) + const expected = start + (i + 1) * intervalMs + const wait = expected - Date.now() + if (wait > 0) await new Promise(r => setTimeout(r, wait)) + } + + await new Promise(r => setTimeout(r, 500)) + const samples = await evalInPage(cdp, `window.__keypressTimings`) + log(`${samples.length} keystroke samples measured out of ${text.length} typed`) + + // Clear composer for next run + await evalInPage(cdp, ` + (() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (el) { el.innerHTML = ''; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) } + window.__keystrokeObserver?.disconnect() + })() + `) + + const mutLat = samples.map(s => s.mutationLatency).sort((a, b) => a - b) + const paintLat = samples.map(s => s.paintLatency).sort((a, b) => a - b) + const stat = arr => ({ + n: arr.length, + min: arr[0]?.toFixed(2), + p50: arr[Math.floor(arr.length * 0.5)]?.toFixed(2), + p90: arr[Math.floor(arr.length * 0.9)]?.toFixed(2), + p95: arr[Math.floor(arr.length * 0.95)]?.toFixed(2), + p99: arr[Math.floor(arr.length * 0.99)]?.toFixed(2), + max: arr[arr.length - 1]?.toFixed(2), + mean: arr.length ? (arr.reduce((s, x) => s + x, 0) / arr.length).toFixed(2) : 0 + }) + + console.log('\n=== keypress → mutation latency (ms) ===') + console.log(' ', stat(mutLat)) + console.log('\n=== keypress → next rAF (≈paint) latency (ms) ===') + console.log(' ', stat(paintLat)) + + const slow = samples.filter(s => s.paintLatency > 16) + console.log(`\n=== ${slow.length}/${samples.length} keystrokes >16ms (one frame) ===`) + if (slow.length) { + const slowSorted = [...slow].sort((a, b) => b.paintLatency - a.paintLatency).slice(0, 10) + for (const s of slowSorted) { + console.log(` paint=${s.paintLatency.toFixed(1)}ms mut=${s.mutationLatency.toFixed(1)}ms at t=${s.start.toFixed(0)}`) + } + } + + writeFileSync('/tmp/hermes-latency-samples.json', JSON.stringify(samples, null, 2)) + + cdp.close() +} + +main().catch(e => { + console.error('[latency] fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/measure-real-stream.mjs b/apps/desktop/scripts/measure-real-stream.mjs new file mode 100644 index 000000000..57eee502d --- /dev/null +++ b/apps/desktop/scripts/measure-real-stream.mjs @@ -0,0 +1,252 @@ +// REAL streaming measurement — no React internals. +// +// Measures: +// 1) rAF frame intervals during a verified live stream (long-frame histogram) +// 2) MutationObserver: how often does the live assistant message mutate, what's the budget per mutation +// 3) Text length growth rate (chars/sec) +// 4) PerformanceObserver `longtask` entries (any task > 50ms blocks input) +// +// Detects REAL stream by waiting for assistant-message DOM count to grow past baseline. +// Does NOT cancel — lets the stream run to completion or hits TIMEOUT_MS. + +const CDP_HTTP = 'http://127.0.0.1:9222' +const PROMPT = process.env.PROMPT || 'count from 1 to 80, one number per line' +const TIMEOUT_MS = Number(process.env.TIMEOUT_MS || 60000) + +async function getTarget() { + const list = await (await fetch(`${CDP_HTTP}/json`)).json() + const t = list.find((t) => t.type === 'page' && /5174/.test(t.url)) + if (!t) throw new Error('renderer not found') + return t +} + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() } + static async open(url) { + const ws = new WebSocket(url) + await new Promise((r, j) => { + ws.addEventListener('open', r, { once: true }) + ws.addEventListener('error', (e) => j(e), { once: true }) + }) + const cdp = new CDP(ws) + ws.addEventListener('message', (event) => { + const m = JSON.parse(event.data.toString()) + if (m.id != null && cdp.pending.has(m.id)) { + const { resolve, reject } = cdp.pending.get(m.id) + cdp.pending.delete(m.id) + if (m.error) reject(new Error(m.error.message)) + else resolve(m.result) + } + }) + return cdp + } + send(method, params) { + const id = ++this.id + return new Promise((res, rej) => { + this.pending.set(id, { resolve: res, reject: rej }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + async eval(expr) { + const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval') + return r.result.value + } + close() { this.ws.close() } +} + +async function main() { + const target = await getTarget() + const cdp = await CDP.open(target.webSocketDebuggerUrl) + + // Install recorders. + await cdp.eval(` + (() => { + // rAF frame intervals + window.__FT__ = { times: [], stop: false } + let last = performance.now() + const tick = () => { + if (window.__FT__.stop) return + const now = performance.now() + window.__FT__.times.push(now - last) + last = now + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + + // longtask observer + window.__LT__ = { entries: [], stop: false } + try { + const po = new PerformanceObserver((list) => { + if (window.__LT__.stop) return + for (const e of list.getEntries()) { + window.__LT__.entries.push({ name: e.name, duration: e.duration, startTime: e.startTime }) + } + }) + po.observe({ entryTypes: ['longtask'] }) + window.__LT__.po = po + } catch {} + + // mutation observer on streaming message + window.__MO__ = { mutations: [], stop: false, currentMsg: null } + const tryArm = () => { + const all = document.querySelectorAll('[data-slot="aui_assistant-message-root"]') + const last = all[all.length - 1] + if (!last || last === window.__MO__.currentMsg) return + window.__MO__.currentMsg = last + if (window.__MO__.obs) window.__MO__.obs.disconnect() + const obs = new MutationObserver((muts) => { + if (window.__MO__.stop) return + const t = performance.now() + window.__MO__.mutations.push({ t, count: muts.length, len: last.textContent.length }) + }) + obs.observe(last, { childList: true, subtree: true, characterData: true }) + window.__MO__.obs = obs + } + window.__MO__.arm = tryArm + return 'recorders armed' + })() + `) + + // Baseline + const base = JSON.parse(await cdp.eval(` + JSON.stringify({ + assistantCount: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length, + busy: !!document.querySelector('[data-status="running"], [data-busy="true"]'), + hasComposer: !!document.querySelector('[contenteditable="true"]'), + }) + `)) + console.log('baseline:', base) + if (!base.hasComposer) { console.error('no composer'); cdp.close(); return } + + // Type + submit + await cdp.eval(` + (() => { + const ed = document.querySelector('[contenteditable="true"]') + ed.focus() + document.execCommand('insertText', false, ${JSON.stringify(PROMPT)}) + return 'typed' + })() + `) + const submitT0 = Date.now() + await cdp.eval(` + (() => { + const ed = document.querySelector('[contenteditable="true"]') + ed.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true })) + return 'submitted' + })() + `) + + // Poll for REAL stream (assistant count > baseline). 30 seconds — accommodates + // slow first-token latencies on big providers. + let realStreamT = null + for (let i = 0; i < 600; i++) { + await new Promise((r) => setTimeout(r, 50)) + const s = JSON.parse(await cdp.eval(` + JSON.stringify({ + n: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length, + busy: !!document.querySelector('[data-status="running"], [data-busy="true"]'), + text: (() => { const a = document.querySelectorAll('[data-slot="aui_assistant-message-root"]'); return a.length ? a[a.length-1].textContent.length : 0 })() + }) + `)) + if (s.n > base.assistantCount) { + realStreamT = Date.now() + console.log('REAL stream started after', realStreamT - submitT0, 'ms — busy=', s.busy, 'text=', s.text) + // Arm mutation observer on the new message + await cdp.eval('window.__MO__.arm()') + break + } + } + if (!realStreamT) { + console.error('REAL STREAM NEVER STARTED') + cdp.close() + return + } + + // Sample length growth, wait for completion or timeout + const samples = [] + const start = Date.now() + while (Date.now() - start < TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, 250)) + const s = JSON.parse(await cdp.eval(` + JSON.stringify({ + t: performance.now(), + len: (() => { const a = document.querySelectorAll('[data-slot="aui_assistant-message-root"]'); return a.length ? a[a.length-1].textContent.length : 0 })(), + busy: !!document.querySelector('[data-status="running"], [data-busy="true"]') + }) + `)) + samples.push(s) + if (!s.busy && samples.length > 4) { + await new Promise((r) => setTimeout(r, 300)) + break + } + } + + // Pull recordings + const data = JSON.parse(await cdp.eval(` + (() => { + window.__FT__.stop = true + window.__LT__.stop = true + window.__MO__.stop = true + try { window.__LT__.po && window.__LT__.po.disconnect() } catch {} + try { window.__MO__.obs && window.__MO__.obs.disconnect() } catch {} + return JSON.stringify({ + frames: window.__FT__.times, + longtasks: window.__LT__.entries, + mutations: window.__MO__.mutations, + }) + })() + `)) + + const { frames, longtasks, mutations } = data + + // Frame histogram (filter to stream window) + const buckets = { '<=16.7': 0, '16.7-33': 0, '33-50': 0, '50-100': 0, '100-200': 0, '>200': 0 } + let frameTotal = 0 + let maxFrame = 0 + for (const f of frames) { + frameTotal += f + if (f > maxFrame) maxFrame = f + if (f <= 16.7) buckets['<=16.7']++ + else if (f <= 33) buckets['16.7-33']++ + else if (f <= 50) buckets['33-50']++ + else if (f <= 100) buckets['50-100']++ + else if (f <= 200) buckets['100-200']++ + else buckets['>200']++ + } + const avgFps = frames.length ? (frames.length / (frameTotal / 1000)).toFixed(1) : 'n/a' + const slowFrames = frames.filter((f) => f > 33).length + const veryslowFrames = frames.filter((f) => f > 100).length + + // Longtask summary + const ltMs = longtasks.reduce((a, b) => a + b.duration, 0) + const ltMax = longtasks.length ? Math.max(...longtasks.map((e) => e.duration)) : 0 + + // Mutation rate + let mutTotal = mutations.length + let mutDurs = [] + for (let i = 1; i < mutations.length; i++) { + mutDurs.push(mutations[i].t - mutations[i - 1].t) + } + mutDurs.sort((a, b) => a - b) + const mutP50 = mutDurs[Math.floor(mutDurs.length * 0.5)] ?? 0 + const mutP95 = mutDurs[Math.floor(mutDurs.length * 0.95)] ?? 0 + + // Growth rate + const firstLen = samples[0]?.len ?? 0 + const lastLen = samples[samples.length - 1]?.len ?? 0 + const elapsedS = samples.length ? (samples[samples.length - 1].t - samples[0].t) / 1000 : 0 + const charsPerSec = elapsedS ? ((lastLen - firstLen) / elapsedS).toFixed(1) : 'n/a' + + console.log('\n=== STREAM RESULTS ===') + console.log('window:', (frameTotal / 1000).toFixed(1), 's | frames:', frames.length, '| avgFps:', avgFps, '| maxFrame:', maxFrame.toFixed(1), 'ms') + console.log('frame histogram:', buckets) + console.log('slow frames (>33ms):', slowFrames, '| very slow (>100ms):', veryslowFrames) + console.log('longtasks:', longtasks.length, 'total', ltMs.toFixed(0), 'ms — max', ltMax.toFixed(1), 'ms') + console.log('text grew', firstLen, '→', lastLen, 'chars (', charsPerSec, 'char/s )') + console.log('mutations on streaming msg:', mutTotal, '| inter-mutation p50:', mutP50.toFixed(1), 'ms', 'p95:', mutP95.toFixed(1), 'ms') + + cdp.close() +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/desktop/scripts/measure-submit.mjs b/apps/desktop/scripts/measure-submit.mjs new file mode 100644 index 000000000..6c89c44e3 --- /dev/null +++ b/apps/desktop/scripts/measure-submit.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +// Measure submit (Enter) latency in the composer. +// +// For each round: +// 1. Focus composer, type N chars of stub text +// 2. Mark a timestamp, fire Enter via Input.dispatchKeyEvent +// 3. Observe: time until the composer becomes empty (submit accepted), +// time until the user message renders in the thread viewport, +// time until the optional "running…" indicator appears, +// time until the next frame is painted after the message renders. +// +// Pre-condition: a session is loaded (load via click-session.mjs first). +// Note: this DOES talk to the real gateway/agent, so each round triggers +// a real prompt submission. Don't run this on a live conversation +// you care about — use a throwaway session. + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) +const PORT = Number(args.port ?? 9222) +const ROUNDS = Number(args.rounds ?? 3) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + return list.find(t => t.type === 'page' && t.url.startsWith('http')) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } + }) + }) +} + +async function evalP(cdp, expr) { + const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function focusAndType(cdp, text) { + await evalP(cdp, ` + (() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return + el.focus() + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + })() + `) + for (const c of text) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 8)) + } +} + +async function submitAndMeasure(cdp, timeoutMs = 5000) { + // Install observers, record submit time as performance.now() inside the page, + // and wait for all milestones. + return await evalP(cdp, ` + new Promise((resolve) => { + const composer = document.querySelector('[data-slot="composer-rich-input"]') + const threadRoot = document.querySelector('[data-slot="aui_thread-content"]') || + document.querySelector('[data-slot="aui_thread-viewport"]') + const startMessageCount = threadRoot ? threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length : 0 + const startComposerText = composer ? composer.innerText : '' + + const milestones = { start: performance.now() } + let done = false + const finish = (reason) => { + if (done) return + done = true + clearInterval(poll); clearTimeout(timer) + composerObs.disconnect() + threadObs?.disconnect() + milestones.reason = reason + milestones.end = performance.now() + milestones.totalMs = milestones.end - milestones.start + resolve(milestones) + } + + const composerObs = new MutationObserver(() => { + if (!milestones.composerClearedMs && composer && composer.innerText.length === 0) { + milestones.composerClearedMs = performance.now() - milestones.start + } + }) + composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true }) + + let threadObs = null + if (threadRoot) { + threadObs = new MutationObserver(() => { + const c = threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length + if (!milestones.userMessageRenderedMs && c > startMessageCount) { + milestones.userMessageRenderedMs = performance.now() - milestones.start + requestAnimationFrame(() => { + milestones.userMessagePaintMs = performance.now() - milestones.start + finish('paint') + }) + } + }) + threadObs.observe(threadRoot, { childList: true, subtree: true }) + } + + const poll = setInterval(() => { + if (milestones.composerClearedMs && !milestones.userMessageRenderedMs && + performance.now() - milestones.start > 2000) { + finish('timeout-after-clear') + } + }, 100) + const timer = setTimeout(() => finish('timeout-overall'), ${timeoutMs}) + + // Send Enter immediately + window.dispatchEvent(new KeyboardEvent('keydown')) // no-op marker + const enterEv = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }) + composer?.dispatchEvent(enterEv) + }) + `) +} + +async function main() { + const tgt = await pickRenderer() + console.log('target', tgt.url) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + + const samples = [] + for (let i = 1; i <= ROUNDS; i++) { + await focusAndType(cdp, `latency test ${i} ${'x'.repeat(40)}`) + await new Promise(r => setTimeout(r, 300)) + const result = await submitAndMeasure(cdp, 4000) + samples.push({ round: i, ...result }) + console.log( + `r${i}: clear=${(result.composerClearedMs ?? -1).toFixed?.(0) ?? '?'}ms ` + + `userMsg=${(result.userMessageRenderedMs ?? -1).toFixed?.(0) ?? '?'}ms ` + + `paint=${(result.userMessagePaintMs ?? -1).toFixed?.(0) ?? '?'}ms ` + + `reason=${result.reason}` + ) + // wait for any agent activity to finish before next round so we're not piling up + await new Promise(r => setTimeout(r, 4000)) + } + writeFileSync('/tmp/hermes-submit-latency.json', JSON.stringify(samples, null, 2)) + console.log('\nwrote /tmp/hermes-submit-latency.json') + cdp.close() +} + +main().catch(e => { + console.error('fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/measure-synthetic-stream.mjs b/apps/desktop/scripts/measure-synthetic-stream.mjs new file mode 100644 index 000000000..3b8afb297 --- /dev/null +++ b/apps/desktop/scripts/measure-synthetic-stream.mjs @@ -0,0 +1,322 @@ +// Measure render cost of a synthetic stream driven through the live $messages atom. +// +// Why synthetic: the user's LLM credits are depleted; we can't fire a real stream. +// The synthetic stream exercises the exact same React pipeline (assistant-ui runtime → +// repository.addOrUpdateMessage → MessagePrimitive re-render → markdown reflow) as a +// real stream. The only thing it does NOT exercise is the gateway → SSE → optimistic- +// merge path, which is orthogonal to the rendering question. +// +// What we record: +// 1) rAF frame intervals (long-frame histogram; >33ms = perceived jank, >100ms = bad) +// 2) PerformanceObserver `longtask` entries (task >50ms blocks input) +// 3) MutationObserver: per-message mutation count & inter-mutation latency +// 4) Optional: typing latency overlay — typing into composer while streaming +// +// Output is plain text suitable for terminal + a JSON sidecar for diffing across runs. + +import { writeFileSync } from 'node:fs' + +const CDP_HTTP = 'http://127.0.0.1:9222' +const TOKENS = Number(process.env.TOKENS || 300) +const INTERVAL_MS = Number(process.env.INTERVAL_MS || 16) +// Upstream flush throttle to apply in the synthetic driver. Mirrors what the +// real gateway path does in `use-message-stream.scheduleDeltaFlush`. 0 +// disables (worst-case, every token = one React commit). +const FLUSH_MIN_MS = Number(process.env.FLUSH_MIN_MS || 0) +const CHUNK = process.env.CHUNK || 'lorem ipsum ' +const TYPE_WHILE_STREAMING = process.env.TYPE_WHILE_STREAMING === '1' +const LABEL = process.env.LABEL || 'baseline' +const OUT = process.env.OUT || `frame-times-${LABEL}.json` + +async function getTarget() { + const list = await (await fetch(`${CDP_HTTP}/json`)).json() + const t = list.find((t) => t.type === 'page' && /5174/.test(t.url)) + if (!t) throw new Error('renderer not found') + return t +} + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() } + static async open(url) { + const ws = new WebSocket(url) + await new Promise((r, j) => { + ws.addEventListener('open', r, { once: true }) + ws.addEventListener('error', (e) => j(e), { once: true }) + }) + const cdp = new CDP(ws) + ws.addEventListener('message', (ev) => { + const m = JSON.parse(ev.data.toString()) + if (m.id != null && cdp.pending.has(m.id)) { + const { resolve, reject } = cdp.pending.get(m.id) + cdp.pending.delete(m.id) + if (m.error) reject(new Error(m.error.message)) + else resolve(m.result) + } + }) + return cdp + } + send(method, params) { + const id = ++this.id + return new Promise((res, rej) => { + this.pending.set(id, { resolve: res, reject: rej }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + async eval(expr) { + const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval') + return r.result.value + } + close() { this.ws.close() } +} + +function pct(arr, p) { + if (!arr.length) return 0 + const i = Math.min(arr.length - 1, Math.floor(arr.length * p)) + return arr[i] +} + +async function main() { + const target = await getTarget() + const cdp = await CDP.open(target.webSocketDebuggerUrl) + + // Sanity check driver is loaded. + const probeOk = await cdp.eval('!!window.__PERF_DRIVE__ && !!window.__PERF_DRIVE__.stream') + if (!probeOk) { + console.error('__PERF_DRIVE__ not on window — did you reload the renderer after editing perf-probe.tsx?') + cdp.close() + process.exit(2) + } + + // Install recorders. + await cdp.eval(` + (() => { + window.__FT__ = { times: [], stop: false } + let last = performance.now() + const tick = () => { + if (window.__FT__.stop) return + const now = performance.now() + window.__FT__.times.push(now - last) + last = now + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + + window.__LT__ = { entries: [], stop: false } + try { + const po = new PerformanceObserver((list) => { + if (window.__LT__.stop) return + for (const e of list.getEntries()) { + window.__LT__.entries.push({ name: e.name, duration: e.duration, startTime: e.startTime }) + } + }) + po.observe({ entryTypes: ['longtask'] }) + window.__LT__.po = po + } catch {} + + window.__MO__ = { mutations: [], stop: false, currentMsg: null } + const arm = () => { + const all = document.querySelectorAll('[data-slot="aui_assistant-message-root"]') + const last = all[all.length - 1] + if (!last || last === window.__MO__.currentMsg) return + window.__MO__.currentMsg = last + if (window.__MO__.obs) window.__MO__.obs.disconnect() + const obs = new MutationObserver((muts) => { + if (window.__MO__.stop) return + const t = performance.now() + window.__MO__.mutations.push({ t, count: muts.length, len: last.textContent.length }) + }) + obs.observe(last, { childList: true, subtree: true, characterData: true }) + window.__MO__.obs = obs + } + window.__MO__.arm = arm + + // Optional: typing observer — fires keystroke timings if asked. + window.__TYP__ = { times: [], stop: false, lastKey: 0 } + return 'recorders armed' + })() + `) + + // Baseline state. + const base = JSON.parse(await cdp.eval(` + JSON.stringify({ + assistantCount: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length, + atomCount: window.__PERF_DRIVE__.snapshotMsgs() + }) + `)) + console.log('baseline:', base) + + // Drive a synthetic stream. + const streamStart = Date.now() + await cdp.eval(`window.__PERF_DRIVE__.stream({ chunk: ${JSON.stringify(CHUNK)}, intervalMs: ${INTERVAL_MS}, totalTokens: ${TOKENS}, flushMinMs: ${FLUSH_MIN_MS} })`) + + // After the first paint, arm MO on the new message. + await new Promise((r) => setTimeout(r, 200)) + await cdp.eval('window.__MO__.arm()') + + // Optional: type while streaming. + if (TYPE_WHILE_STREAMING) { + await new Promise((r) => setTimeout(r, 400)) + await cdp.eval(`(() => { + const ed = document.querySelector('[contenteditable="true"]') + ed.focus() + window.__TYP__.startedAt = performance.now() + const text = 'the quick brown fox jumps over the lazy dog ' + let i = 0 + const tick = () => { + if (i >= text.length) return + const t0 = performance.now() + document.execCommand('insertText', false, text[i]) + // requestAnimationFrame to wait for next paint + requestAnimationFrame(() => { + window.__TYP__.times.push(performance.now() - t0) + }) + i++ + setTimeout(tick, 60) + } + tick() + return 'typing' + })()`) + } + + // Wait for stream to complete + small grace. + const expectedMs = TOKENS * INTERVAL_MS + 1500 + await new Promise((r) => setTimeout(r, expectedMs)) + + // Pull recordings. + const data = JSON.parse(await cdp.eval(` + (() => { + window.__FT__.stop = true + window.__LT__.stop = true + window.__MO__.stop = true + window.__TYP__.stop = true + try { window.__LT__.po && window.__LT__.po.disconnect() } catch {} + try { window.__MO__.obs && window.__MO__.obs.disconnect() } catch {} + return JSON.stringify({ + frames: window.__FT__.times, + longtasks: window.__LT__.entries, + mutations: window.__MO__.mutations, + typing: window.__TYP__.times, + finalText: (() => { const a = document.querySelectorAll('[data-slot="aui_assistant-message-root"]'); return a.length ? a[a.length-1].textContent.length : 0 })() + }) + })() + `)) + + // Reset DOM back to baseline so we don't accumulate fake messages. + await cdp.eval('window.__PERF_DRIVE__.reset()') + + // Analysis (trim warm-up: drop frames before first mutation timestamp). + const firstMut = data.mutations[0]?.t + const frames = data.frames + + // Sum durations to figure out when each frame happened (relative to recorder start). + const frameTimeline = [] + let acc = 0 + for (const f of frames) { acc += f; frameTimeline.push(acc) } + + // Mutations are in performance.now() ms; frames started recording when we installed + // the recorder (before stream). To align: compute total stream window from frames + // after mutation activity began. Simpler heuristic: drop first 500ms of frames as warm-up. + const WARMUP_MS = 500 + let dropIdx = 0 + for (let i = 0; i < frames.length; i++) { + if (frameTimeline[i] >= WARMUP_MS) { dropIdx = i; break } + } + const streamFrames = frames.slice(dropIdx) + + const buckets = { '<=16.7': 0, '16.7-33': 0, '33-50': 0, '50-100': 0, '100-200': 0, '>200': 0 } + let frameTotal = 0 + let maxFrame = 0 + for (const f of streamFrames) { + frameTotal += f + if (f > maxFrame) maxFrame = f + if (f <= 16.7) buckets['<=16.7']++ + else if (f <= 33) buckets['16.7-33']++ + else if (f <= 50) buckets['33-50']++ + else if (f <= 100) buckets['50-100']++ + else if (f <= 200) buckets['100-200']++ + else buckets['>200']++ + } + const sortedFrames = [...streamFrames].sort((a, b) => a - b) + const fAvgFps = streamFrames.length ? (streamFrames.length / (frameTotal / 1000)).toFixed(1) : 'n/a' + const fP50 = pct(sortedFrames, 0.5).toFixed(1) + const fP95 = pct(sortedFrames, 0.95).toFixed(1) + const fP99 = pct(sortedFrames, 0.99).toFixed(1) + const slowFrames = streamFrames.filter((f) => f > 33).length + const veryslowFrames = streamFrames.filter((f) => f > 100).length + + const ltDur = data.longtasks.map((e) => e.duration).sort((a, b) => a - b) + const ltMs = ltDur.reduce((a, b) => a + b, 0) + const ltMax = ltDur.length ? ltDur[ltDur.length - 1] : 0 + const ltP95 = pct(ltDur, 0.95) + + // Mutation cadence. + const mutDurs = [] + for (let i = 1; i < data.mutations.length; i++) mutDurs.push(data.mutations[i].t - data.mutations[i - 1].t) + mutDurs.sort((a, b) => a - b) + const mutP50 = pct(mutDurs, 0.5) + const mutP95 = pct(mutDurs, 0.95) + const mutMax = mutDurs.length ? mutDurs[mutDurs.length - 1] : 0 + + // Typing latency (optional). + let typingSummary = null + if (TYPE_WHILE_STREAMING && data.typing.length) { + const t = [...data.typing].sort((a, b) => a - b) + typingSummary = { + n: t.length, + p50: pct(t, 0.5).toFixed(1), + p95: pct(t, 0.95).toFixed(1), + max: t[t.length - 1].toFixed(1) + } + } + + const result = { + label: LABEL, + timestamp: new Date().toISOString(), + config: { TOKENS, INTERVAL_MS, CHUNK, TYPE_WHILE_STREAMING, FLUSH_MIN_MS }, + streamWallMs: Date.now() - streamStart, + frames: { + total: streamFrames.length, + avgFps: fAvgFps, + windowS: (frameTotal / 1000).toFixed(1), + p50: fP50, + p95: fP95, + p99: fP99, + max: maxFrame.toFixed(1), + slow33: slowFrames, + veryslow100: veryslowFrames, + histogram: buckets + }, + longtasks: { + n: data.longtasks.length, + totalMs: ltMs.toFixed(0), + maxMs: ltMax.toFixed(1), + p95Ms: ltP95.toFixed(1) + }, + mutations: { + n: data.mutations.length, + finalTextLen: data.finalText, + interMutP50ms: mutP50.toFixed(1), + interMutP95ms: mutP95.toFixed(1), + interMutMaxMs: mutMax.toFixed(1) + }, + typing: typingSummary + } + + writeFileSync(OUT, JSON.stringify(result, null, 2)) + + console.log('\n=== SYNTHETIC STREAM RESULTS ===') + console.log('label:', LABEL, '| tokens:', TOKENS, '@', INTERVAL_MS, 'ms') + console.log('streamWallMs:', result.streamWallMs) + console.log('FRAMES: avgFps', fAvgFps, '| p50', fP50, 'ms | p95', fP95, 'ms | p99', fP99, 'ms | max', maxFrame.toFixed(1), 'ms') + console.log('FRAMES histogram:', buckets) + console.log('FRAMES slow(>33):', slowFrames, '/ veryslow(>100):', veryslowFrames, 'of', streamFrames.length) + console.log('LONGTASKS:', data.longtasks.length, '| total', ltMs.toFixed(0), 'ms | max', ltMax.toFixed(1), 'ms | p95', ltP95.toFixed(1), 'ms') + console.log('MUTATIONS:', data.mutations.length, '| finalLen', data.finalText, 'chars | inter p50', mutP50.toFixed(1), 'ms | p95', mutP95.toFixed(1), 'ms') + if (typingSummary) console.log('TYPING-WHILE-STREAMING latency: p50', typingSummary.p50, 'ms | p95', typingSummary.p95, 'ms | n=', typingSummary.n) + console.log('written to', OUT) + + cdp.close() +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/desktop/scripts/notarize-artifact.cjs b/apps/desktop/scripts/notarize-artifact.cjs new file mode 100644 index 000000000..89a4901c5 --- /dev/null +++ b/apps/desktop/scripts/notarize-artifact.cjs @@ -0,0 +1,77 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFile } = require('node:child_process') + +function run(command, args) { + return new Promise((resolve, reject) => { + execFile(command, args, (error, stdout, stderr) => { + if (error) { + // Intentionally omit args from the rejection message: callers pass + // notarization credentials (key id, issuer, key file path) here, and + // surfacing them in error output would land in CI logs. + reject(new Error(`${command} failed: ${stderr?.trim() || stdout?.trim() || error.message}`)) + return + } + resolve() + }) + }) +} + +function inlineKeyLooksValid(value) { + return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY') +} + +function resolveApiKeyPath(rawValue) { + const value = String(rawValue || '').trim() + if (!value) return { keyPath: '', cleanup: () => {} } + + if (fs.existsSync(value)) { + return { keyPath: value, cleanup: () => {} } + } + + if (!inlineKeyLooksValid(value)) { + throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content') + } + + const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) + fs.writeFileSync(tempPath, value, 'utf8') + return { + keyPath: tempPath, + cleanup: () => fs.rmSync(tempPath, { force: true }) + } +} + +async function main() { + const artifactPath = process.argv[2] + if (!artifactPath || !fs.existsSync(artifactPath)) { + throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`) + } + + const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim() + if (profile) { + await run('xcrun', ['notarytool', 'submit', artifactPath, '--keychain-profile', profile, '--wait']) + await run('xcrun', ['stapler', 'staple', '-v', artifactPath]) + return + } + + const keyId = String(process.env.APPLE_API_KEY_ID || '').trim() + const issuer = String(process.env.APPLE_API_ISSUER || '').trim() + const rawApiKey = process.env.APPLE_API_KEY + if (!rawApiKey || !keyId || !issuer) { + throw new Error('APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are required') + } + + const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey) + try { + await run('xcrun', ['notarytool', 'submit', artifactPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait']) + await run('xcrun', ['stapler', 'staple', '-v', artifactPath]) + } finally { + cleanup() + } +} + +main().catch(() => { + console.error('Notarization failed. Check configuration and command output in secure CI logs.') + process.exit(1) +}) diff --git a/apps/desktop/scripts/notarize.cjs b/apps/desktop/scripts/notarize.cjs new file mode 100644 index 000000000..1508e18e8 --- /dev/null +++ b/apps/desktop/scripts/notarize.cjs @@ -0,0 +1,100 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFile } = require('node:child_process') + +function run(command, args) { + return new Promise((resolve, reject) => { + execFile(command, args, (error, stdout, stderr) => { + if (error) { + reject( + new Error( + `${command} ${args.join(' ')} failed: ${stderr?.trim() || stdout?.trim() || error.message}` + ) + ) + return + } + resolve({ stdout, stderr }) + }) + }) +} + +function inlineKeyLooksValid(value) { + return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY') +} + +function resolveApiKeyPath(rawValue) { + const value = String(rawValue || '').trim() + if (!value) return { keyPath: '', cleanup: () => {} } + + if (fs.existsSync(value)) { + return { keyPath: value, cleanup: () => {} } + } + + if (!inlineKeyLooksValid(value)) { + throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content') + } + + const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) + fs.writeFileSync(tempPath, value, 'utf8') + return { + keyPath: tempPath, + cleanup: () => { + try { + fs.rmSync(tempPath, { force: true }) + } catch { + // Best-effort cleanup. + } + } + } +} + +exports.default = async function notarize(context) { + const { electronPlatformName, appOutDir, packager } = context + if (electronPlatformName !== 'darwin') return + + const appName = packager.appInfo.productFilename + const appPath = path.join(appOutDir, `${appName}.app`) + if (!fs.existsSync(appPath)) { + throw new Error(`Cannot notarize missing app bundle: ${appPath}`) + } + + const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim() + if (profile) { + const zipPath = path.join(appOutDir, `${appName}.zip`) + await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath]) + await run('xcrun', ['notarytool', 'submit', zipPath, '--keychain-profile', profile, '--wait']) + await run('xcrun', ['stapler', 'staple', '-v', appPath]) + try { + fs.rmSync(zipPath, { force: true }) + } catch { + // Best-effort cleanup. + } + return + } + + const keyId = String(process.env.APPLE_API_KEY_ID || '').trim() + const issuer = String(process.env.APPLE_API_ISSUER || '').trim() + const rawApiKey = process.env.APPLE_API_KEY + if (!rawApiKey || !keyId || !issuer) { + console.log( + 'Skipping notarization: APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are not fully configured.' + ) + return + } + + const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey) + const zipPath = path.join(appOutDir, `${appName}.zip`) + try { + await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath]) + await run('xcrun', ['notarytool', 'submit', zipPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait']) + await run('xcrun', ['stapler', 'staple', '-v', appPath]) + } finally { + try { + fs.rmSync(zipPath, { force: true }) + } catch { + // Best-effort cleanup. + } + cleanup() + } +} diff --git a/apps/desktop/scripts/probe-renderer.mjs b/apps/desktop/scripts/probe-renderer.mjs new file mode 100644 index 000000000..fb0633b73 --- /dev/null +++ b/apps/desktop/scripts/probe-renderer.mjs @@ -0,0 +1,38 @@ +// quick probe — read state of the renderer +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +console.log('target:', tgt?.url) +if (!tgt) process.exit(1) +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (method, params = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method, params })) + }) + +const r = await send('Runtime.evaluate', { + expression: `({ + url: location.href, + title: document.title, + rootChildren: document.getElementById('root')?.children.length ?? 0, + rootInner: (document.getElementById('root')?.innerHTML ?? '').slice(0, 300), + hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'), + bootStage: (document.querySelector('[data-slot*="boot"]')?.getAttribute('data-slot')) ?? null, + bodyText: document.body.innerText.slice(0, 300), + errorCount: window.__errors?.length ?? 'n/a' + })`, + returnByValue: true +}) +console.log('raw:', JSON.stringify(r, null, 2)) +ws.close() diff --git a/apps/desktop/scripts/probe-thread.mjs b/apps/desktop/scripts/probe-thread.mjs new file mode 100644 index 000000000..51b5965a7 --- /dev/null +++ b/apps/desktop/scripts/probe-thread.mjs @@ -0,0 +1,40 @@ +// Probe the cloud shadows thread state — count messages, turn pairs, +// thread height, composer state +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (m, p = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method: m, params: p })) + }) + +const r = await send('Runtime.evaluate', { + expression: `JSON.stringify({ + url: location.href, + title: document.title, + turnPairs: document.querySelectorAll('[data-slot="aui_turn-pair"]').length, + assistantMsgs: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length, + userMsgs: document.querySelectorAll('[data-message-role="user"], [data-slot="aui_user-message-root"]').length, + totalDomNodes: document.querySelectorAll('*').length, + threadViewportScrollHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollHeight ?? null, + threadViewportClientHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.clientHeight ?? null, + threadViewportScrollTop: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollTop ?? null, + composer: !!document.querySelector('[data-slot="composer-rich-input"]'), + busy: !!document.querySelector('[aria-label*="Stop"]') + })`, + returnByValue: true +}) +console.log(JSON.parse(r.result.result.value)) +ws.close() diff --git a/apps/desktop/scripts/profile-long-stream.mjs b/apps/desktop/scripts/profile-long-stream.mjs new file mode 100644 index 000000000..b0ae79221 --- /dev/null +++ b/apps/desktop/scripts/profile-long-stream.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Long-running stream profile + frame-rate timeline. Submits a prompt that +// asks for ~30 paragraphs of output, then captures both a CPU profile and +// a per-100ms frame counter so we can see if FPS sags as the message grows. + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) +const PORT = Number(args.port ?? 9222) +const OUT = String(args.out ?? `/tmp/hermes-long-stream-${Date.now()}`) +const STREAM_SEC = Number(args.seconds ?? 25) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + return list.find(t => t.type === 'page' && t.url.startsWith('http')) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } + }) + }) +} + +async function evalP(cdp, expr) { + const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function main() { + const tgt = await pickRenderer() + console.log('target', tgt.url) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + await cdp.send('Profiler.enable') + await cdp.send('Performance.enable') + + // Submit a long-form prompt + await evalP( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + el.focus() + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false) + window.getSelection().removeAllRanges(); window.getSelection().addRange(r) + })()` + ) + const prompt = 'write 15 paragraphs about gpu memory bandwidth, memory hierarchies, roofline model, and how modern transformer inference benefits from these. include diagrams in ascii where relevant. no code. fully detailed.' + for (const c of prompt) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 5)) + } + await new Promise(r => setTimeout(r, 200)) + await cdp.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r' + }) + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' }) + + console.log('waiting for assistant…') + let streaming = false + for (let i = 0; i < 100; i++) { + const c = await evalP(cdp, `document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length`) + if (c > 0) { streaming = true; break } + await new Promise(r => setTimeout(r, 100)) + } + if (!streaming) { + console.error('no assistant message') + cdp.close() + return + } + + // Install a per-rAF frame counter + await evalP( + cdp, + `(() => { + window.__fpsSamples = [] + window.__fpsT0 = performance.now() + window.__fpsLast = performance.now() + window.__fpsFrameCount = 0 + window.__fpsHistogram = [] // {t, fps, contentLen} + const tick = () => { + const now = performance.now() + const dt = now - window.__fpsLast + window.__fpsLast = now + window.__fpsFrameCount++ + window.__fpsSamples.push({ t: now - window.__fpsT0, dt }) + if (performance.now() - window.__fpsT0 < ${STREAM_SEC * 1000}) { + requestAnimationFrame(tick) + } + } + requestAnimationFrame(tick) + // Bucket fps every 500ms + window.__fpsBucket = setInterval(() => { + const now = performance.now() + const recentCount = window.__fpsSamples.filter(s => now - window.__fpsT0 - s.t < 500).length + const root = document.querySelector('[data-slot="aui_thread-content"]') + const len = root ? root.innerText.length : 0 + const v = document.querySelector('[data-slot="aui_thread-viewport"]') + window.__fpsHistogram.push({ + t: now - window.__fpsT0, + frames500ms: recentCount, + fps: recentCount * 2, + contentLen: len, + scrollTop: v?.scrollTop ?? 0, + scrollHeight: v?.scrollHeight ?? 0 + }) + }, 500) + })()` + ) + + // Start CPU profile + await cdp.send('Profiler.setSamplingInterval', { interval: 1000 }) + await cdp.send('Profiler.start') + + await new Promise(r => setTimeout(r, STREAM_SEC * 1000)) + + const { profile } = await cdp.send('Profiler.stop') + await evalP(cdp, `clearInterval(window.__fpsBucket)`) + + writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile)) + console.log(`cpu profile → ${OUT}.cpuprofile`) + + // Pull fps histogram + const hist = JSON.parse(await evalP(cdp, `JSON.stringify(window.__fpsHistogram || [])`)) + writeFileSync(`${OUT}.fps.json`, JSON.stringify(hist, null, 2)) + + console.log(`\n=== FPS over time ===`) + console.log(` t(s) fps contentLen scrollTop/scrollHeight`) + for (const h of hist) { + const bar = '█'.repeat(Math.min(40, Math.max(0, Math.round(h.fps / 2)))) + console.log(` ${(h.t / 1000).toFixed(1).padStart(5)} ${String(h.fps).padStart(3)} ${String(h.contentLen).padStart(10)} ${h.scrollTop}/${h.scrollHeight} ${bar}`) + } + + // Top self frames + const total = (profile.endTime - profile.startTime) / 1000 + const intMs = total / Math.max(1, profile.samples?.length ?? 1) + const counts = new Map() + for (const s of profile.samples ?? []) counts.set(s, (counts.get(s) ?? 0) + 1) + const rows = profile.nodes + .map(n => ({ id: n.id, fn: n.callFrame.functionName || '(anon)', url: n.callFrame.url || '', line: n.callFrame.lineNumber, self: counts.get(n.id) ?? 0 })) + .sort((a, b) => b.self - a.self) + .slice(0, 25) + console.log(`\n=== ${total.toFixed(0)}ms wall, ${profile.samples?.length ?? 0} samples (${intMs.toFixed(2)}ms each) ===`) + for (const r of rows) { + if (r.self === 0) break + const url = r.url.replace(/^.*\/src\//, 'src/').replace(/\?.*$/, '').slice(0, 70) + console.log(` ${(r.self * intMs).toFixed(1).padStart(7)}ms (${String(r.self).padStart(4)} samp) ${r.fn.padEnd(45)} ${url}:${r.line}`) + } + + await evalP(cdp, ` + (() => { + for (const b of document.querySelectorAll('button')) { + if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return } + } + })() + `) + + cdp.close() +} + +main().catch(e => { + console.error('fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/profile-real-stream.mjs b/apps/desktop/scripts/profile-real-stream.mjs new file mode 100644 index 000000000..cb5da652b --- /dev/null +++ b/apps/desktop/scripts/profile-real-stream.mjs @@ -0,0 +1,137 @@ +// CPU-profile during a real LLM stream — confirms or refutes whether the +// synthetic stream's hotspots (Streamdown markdown re-parse, FadeText) +// match real-world content. +// +// Run *after* model is set to something fast + cheap (gpt-4o-mini etc.). +// Sends a prompt likely to produce markdown + a numbered list. + +import { writeFileSync } from 'node:fs' + +const CDP_HTTP = 'http://127.0.0.1:9222' +const PROMPT = process.env.PROMPT || 'Give me a numbered list of 8 useful bash one-liners. For each: a brief description, then the command in a code block. No preamble.' +const OUT = process.env.OUT || `/tmp/real-stream-${Date.now()}.cpuprofile` +const START_TIMEOUT = Number(process.env.START_TIMEOUT || 45000) +const STREAM_TIMEOUT = Number(process.env.STREAM_TIMEOUT || 60000) + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() } + static async open(url) { + const ws = new WebSocket(url) + await new Promise((r) => ws.addEventListener('open', r, { once: true })) + const cdp = new CDP(ws) + ws.addEventListener('message', (ev) => { + const m = JSON.parse(ev.data.toString()) + if (m.id != null && cdp.pending.has(m.id)) { + const { resolve, reject } = cdp.pending.get(m.id) + cdp.pending.delete(m.id) + if (m.error) reject(new Error(m.error.message)) + else resolve(m.result) + } + }) + return cdp + } + send(method, params) { + const id = ++this.id + return new Promise((res, rej) => { + this.pending.set(id, { resolve: res, reject: rej }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + async eval(expr) { + const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval') + return r.result.value + } + close() { this.ws.close() } +} + +async function main() { + const list = await (await fetch(`${CDP_HTTP}/json`)).json() + const target = list.find((t) => t.type === 'page' && /5174/.test(t.url)) + const cdp = await CDP.open(target.webSocketDebuggerUrl) + + const baseCount = await cdp.eval('document.querySelectorAll("[data-slot=aui_assistant-message-root]").length') + + // Submit prompt + await cdp.eval(`(() => { + const ed = document.querySelector('[contenteditable="true"]') + ed.focus() + document.execCommand('insertText', false, ${JSON.stringify(PROMPT)}) + ed.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', which: 13, keyCode: 13, bubbles: true, cancelable: true })) + return 'submitted' + })()`) + + // Wait for real stream start (assistant count grows). + const submitT0 = Date.now() + let streamT = null + for (let i = 0; i < START_TIMEOUT / 50; i++) { + await new Promise((r) => setTimeout(r, 50)) + const n = await cdp.eval('document.querySelectorAll("[data-slot=aui_assistant-message-root]").length') + if (n > baseCount) { streamT = Date.now(); break } + } + if (!streamT) { + console.error('stream never started within', START_TIMEOUT, 'ms') + cdp.close() + process.exit(2) + } + console.log('REAL stream started after', streamT - submitT0, 'ms — starting CPU profile NOW') + + // Start CPU profile NOW, only during stream phase. + await cdp.send('Profiler.enable') + await cdp.send('Profiler.setSamplingInterval', { interval: 100 }) + await cdp.send('Profiler.start') + + // Wait until busy goes false + grace, or timeout. + const cutoff = Date.now() + STREAM_TIMEOUT + while (Date.now() < cutoff) { + await new Promise((r) => setTimeout(r, 500)) + const busy = await cdp.eval('!!document.querySelector("[data-status=running], [data-busy=true]")') + if (!busy) { + await new Promise((r) => setTimeout(r, 500)) + break + } + } + + const { profile } = await cdp.send('Profiler.stop') + writeFileSync(OUT, JSON.stringify(profile)) + console.log('wrote', OUT) + + const samples = profile.samples || [] + const timeDeltas = profile.timeDeltas || [] + const nodes = new Map(profile.nodes.map((n) => [n.id, n])) + const selfTime = new Map() + for (let i = 0; i < samples.length; i++) { + const id = samples[i] + const dt = timeDeltas[i] ?? 0 + selfTime.set(id, (selfTime.get(id) || 0) + dt) + } + const ranked = [...selfTime.entries()] + .map(([id, us]) => { + const n = nodes.get(id) + const cf = n?.callFrame || {} + return { + ms: us / 1000, + name: cf.functionName || '(anonymous)', + url: (cf.url || '').slice(-60), + line: cf.lineNumber + } + }) + .filter((x) => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name)) + .sort((a, b) => b.ms - a.ms) + .slice(0, 25) + + const finalText = await cdp.eval(`(() => { + const all = document.querySelectorAll('[data-slot="aui_assistant-message-root"]') + return all.length ? all[all.length-1].textContent.length : 0 + })()`) + console.log('\nfinal assistant message length:', finalText, 'chars') + + console.log('\n=== TOP 25 SELF TIME (ms) DURING REAL STREAM ===') + for (const r of ranked) { + console.log(`${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(40)} ${r.url}:${r.line}`) + } + + cdp.close() +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/desktop/scripts/profile-synth-stream.mjs b/apps/desktop/scripts/profile-synth-stream.mjs new file mode 100644 index 000000000..1cc395c1b --- /dev/null +++ b/apps/desktop/scripts/profile-synth-stream.mjs @@ -0,0 +1,103 @@ +// CPU-profile a synthetic stream — outputs a .cpuprofile and a top-self ranking. +// Open the .cpuprofile in Chrome DevTools Performance panel for a flamegraph. + +import { writeFileSync } from 'node:fs' + +const CDP_HTTP = 'http://127.0.0.1:9222' +const TOKENS = Number(process.env.TOKENS || 400) +const INTERVAL_MS = Number(process.env.INTERVAL_MS || 8) +const CHUNK = process.env.CHUNK || '**word** in _italic_ with `code` ' +const LABEL = process.env.LABEL || 'profile' +const OUT = process.env.OUT || `synth-${LABEL}.cpuprofile` + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() } + static async open(url) { + const ws = new WebSocket(url) + await new Promise((r) => ws.addEventListener('open', r, { once: true })) + const cdp = new CDP(ws) + ws.addEventListener('message', (ev) => { + const m = JSON.parse(ev.data.toString()) + if (m.id != null && cdp.pending.has(m.id)) { + const { resolve, reject } = cdp.pending.get(m.id) + cdp.pending.delete(m.id) + if (m.error) reject(new Error(m.error.message)) + else resolve(m.result) + } + }) + return cdp + } + send(method, params) { + const id = ++this.id + return new Promise((res, rej) => { + this.pending.set(id, { resolve: res, reject: rej }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + async eval(expr) { + const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval') + return r.result.value + } + close() { this.ws.close() } +} + +async function main() { + const list = await (await fetch(`${CDP_HTTP}/json`)).json() + const target = list.find((t) => t.type === 'page' && /5174/.test(t.url)) + const cdp = await CDP.open(target.webSocketDebuggerUrl) + + if (!await cdp.eval('!!window.__PERF_DRIVE__')) { + console.error('no __PERF_DRIVE__') + cdp.close() + process.exit(2) + } + + await cdp.send('Profiler.enable') + // High-resolution sampling: 100us + await cdp.send('Profiler.setSamplingInterval', { interval: 100 }) + await cdp.send('Profiler.start') + + await cdp.eval(`window.__PERF_DRIVE__.stream({ chunk: ${JSON.stringify(CHUNK)}, intervalMs: ${INTERVAL_MS}, totalTokens: ${TOKENS} })`) + await new Promise((r) => setTimeout(r, TOKENS * INTERVAL_MS + 1500)) + await cdp.eval('window.__PERF_DRIVE__.reset()') + + const { profile } = await cdp.send('Profiler.stop') + writeFileSync(OUT, JSON.stringify(profile)) + console.log('wrote', OUT) + + // Compute top self time per function. + const samples = profile.samples || [] + const timeDeltas = profile.timeDeltas || [] + const nodes = new Map(profile.nodes.map((n) => [n.id, n])) + const selfTime = new Map() // id -> microseconds + for (let i = 0; i < samples.length; i++) { + const id = samples[i] + const dt = timeDeltas[i] ?? 0 + selfTime.set(id, (selfTime.get(id) || 0) + dt) + } + const ranked = [...selfTime.entries()] + .map(([id, us]) => { + const n = nodes.get(id) + const cf = n?.callFrame || {} + return { + us, + ms: us / 1000, + name: cf.functionName || '(anonymous)', + url: (cf.url || '').slice(-60), + line: cf.lineNumber + } + }) + .filter((x) => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name)) + .sort((a, b) => b.us - a.us) + .slice(0, 30) + + console.log('\n=== TOP 30 SELF TIME (ms) ===') + for (const r of ranked) { + console.log(`${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(40)} ${r.url}:${r.line}`) + } + + cdp.close() +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/desktop/scripts/profile-typing-lag.md b/apps/desktop/scripts/profile-typing-lag.md new file mode 100644 index 000000000..a0b09b92a --- /dev/null +++ b/apps/desktop/scripts/profile-typing-lag.md @@ -0,0 +1,381 @@ +# Profiling renderer typing lag + +Workflow for empirically measuring (and fixing) typing/submit lag in the +desktop chat composer. + +## Quick boot for profiling + +Vite 8 + plugin-react 6 has a known issue where the React Fast Refresh +preamble script isn't injected into `index.html`, so opening Electron at +`http://127.0.0.1:5174` throws `$RefreshReg$ is not defined` on every TSX +module and the React tree never mounts. Workaround: run vite with HMR off. + +```bash +# Terminal A — start dev server without HMR +cd apps/desktop +node scripts/dev-no-hmr.mjs + +# Terminal B — start Electron with CDP exposed +cd apps/desktop +XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 \ + ../../node_modules/.bin/electron --remote-debugging-port=9222 . +``` + +Terminal C is yours to run the harnesses. + +## Harnesses + +All zero-dep — Node 24 built-in `WebSocket` + `fetch`. + +### Typing latency — `measure-latency.mjs` + +Per-keystroke `keypress → next paint` latency, p50/p90/p99/max. +Synthesizes keystrokes via `Input.dispatchKeyEvent` so the run is +reproducible. + +```bash +node apps/desktop/scripts/measure-latency.mjs --chars=120 --cps=20 +``` + +Anything > 16ms is a dropped frame. On a freshly-loaded session +(`scripts/click-session.mjs 'Phaser particle'`) we currently see: + +| | unpatched | patched | +|---|---|---| +| p50 paint | 1.9 ms | 2.0 ms | +| p90 paint | 3.3 ms | 13.7 ms | +| p99 paint | 16.7 ms | 15.2 ms | +| max paint | 20.5 ms | 30.4 ms | +| >16ms drops | 2/120 | 1/120 | + +Roughly even on a quick session — patches don't fix typing latency +under benign synthetic conditions because the existing baseline is +already snappy on synthetic input. The real wins are in the leak counters +(see below). If the user reports typing jank, capture a profile + heap +diff during their actual usage and compare against the synthetic baseline +to identify what condition (long thread, popover open, paste, etc.) +makes the path slow. + +### Leak counters — `leak-typing.mjs` + +Types N chars per round, clears, force-GCs, captures +`Performance.getMetrics` deltas. Reveals leaked event listeners, heap +drift, document node growth, and forced-layout counts. + +```bash +# After clicking into a real session (e.g. via click-session.mjs): +node apps/desktop/scripts/leak-typing.mjs --rounds=8 --chars=200 --cps=50 +``` + +**Real-session numbers (Phaser thread, 8 rounds × 200 chars):** + +| | unpatched (HEAD~2) | patched (HEAD) | +|---|---|---| +| jsListeners growth/round | +0 | +0 | +| DOM nodes growth/round | +0 | +0 | +| heap growth/round | ~0 (V8 housekeeping) | ~0 | +| **forced layouts/char** | **7.02** | **2.35** (3× fewer) | + +The forced-layout count is the load-bearing number — typing into a real +session was triggering ~7 layouts per character on the unpatched build +(scrollHeight reads + per-px CSS var writes + FadeText scrollWidth reads +all stacking up). After the patches it's down to ~2.35/char, which is +Blink's natural cost for a 1px/char-growing contentEditable and can't +be lowered further without architectural changes. + +The initial "+35 listeners/round leak" I called out on the first +unpatched run turned out to be transient warm-up (popovers initializing, +etc.); steady-state listener growth was 0 both before and after. + +### CPU profile + heap snapshot — `profile-typing.mjs` + +Records a CPU profile while typing, plus before/after heap snapshots so +you can do a comparison diff in Chrome DevTools Memory tab. + +```bash +node apps/desktop/scripts/profile-typing.mjs \ + --chars=400 --cps=30 --out=/tmp/hermes-typing +# → /tmp/hermes-typing.cpuprofile (open in Chrome DevTools Performance) +# → /tmp/hermes-typing.before.heapsnapshot +# → /tmp/hermes-typing.after.heapsnapshot +``` + +Loading the cpuprofile: Chrome DevTools → Performance tab → drag the file +in, or VS Code → open the `.cpuprofile` directly. + +For heap diff: Chrome DevTools → Memory → Load snapshot → load "before", +then Comparison view → load "after". Sort by `# Delta`. Stay alert for +detached DOM, FiberNodes (unmounted), and listener growth. + +## Helpers + +- `probe-renderer.mjs` — dump page state (URL, composer mounted?, body text) +- `click-session.mjs ` — click a sidebar session by partial title match +- `reload-renderer.mjs` — force Page.reload via CDP (no HMR available) +- `dump-state.mjs` — richer state dump (thread message count, sticky session, etc.) +- `probe-console.mjs` — dump recent console errors / exceptions + +## Findings + +See commit message for `apps/desktop/src/app/chat/composer/index.tsx` +edits. Three changes: + +1. **Per-keystroke `scrollHeight` read removed.** The expansion useEffect + used to read `editorRef.current.scrollHeight` on every draft change + (forces synchronous layout). Replaced with a `draft.length > 60` + heuristic; the ResizeObserver catches anything the heuristic misses. + +2. **Bucketed CSS custom-property writes.** `syncComposerMetrics` + used to `setProperty('--composer-measured-height', height + 'px')` + on every observed resize, invalidating computed style for the whole + tree. Now writes only when the height crosses an 8 px bucket, so + typing in a fixed-height row produces no style invalidation at all. + +3. **Removed dead `$composerDraft` → `aui.composer().setText` round-trip.** + Nothing outside the composer subscribed to `$composerDraft` (verified + via grep). The two useEffects that pushed draft → store and store → + composer were pure overhead per keystroke. `reconcileComposerTerminalSelections` + was also called per keystroke; can be deferred to submit time (it's a + stale-pruning step, not a correctness one — `terminalContextBlocksFromDraft` + walks the current text directly at submit and ignores stale labels). + +4. **`refreshTrigger` fast-bails when no `@`/`/` in draft.** Previously + `textBeforeCaret()` did `range.toString()` (O(n)) on every keystroke + even when no trigger char was present. + +The biggest win is the listener leak in (3) — without it, each round of +typing leaked ~35 event listeners until a steady state. + +## Submit / TTFT stall (open) + +User reports a perceived stall *after* Enter, before the assistant starts +streaming. `scripts/measure-submit.mjs` measures +`enter → composer-cleared → user-message-rendered → first-paint`. The +script triggers a real prompt submission, so use it on a throwaway +session. Not enabled in CI. + +## Streaming "5fps" investigation (May 21, 2026) + +User complaint: "the streaming must bring fps to like 5? lol" — felt +hitches during assistant streaming on long threads. + +### Tooling added + +- **`src/app/chat/perf-probe.tsx`** — dev-only side-effect import (guarded by + `import.meta.env.MODE !== 'production'` in `main.tsx`). Attaches two + helpers to `window`: + - `__PERF_PROBE__` — React `<Profiler>` recorder. Currently inert because + Vite is serving the production React build (see "Vite dev-build issue" + below); kept for when that's fixed. + - `__PERF_DRIVE__` — synthetic stream driver. Pushes tokens through the + live `$messages` atom at a fixed cadence, so the assistant-ui runtime, + incremental repository, Streamdown markdown renderer, and React commit + pipeline all see the same workload they'd see from a real LLM stream — + but with no LLM call (and no credit cost). +- **`scripts/measure-synthetic-stream.mjs`** — drives `__PERF_DRIVE__`, + records rAF frame intervals, `PerformanceObserver({entryTypes:['longtask']})` + entries, `MutationObserver` cadence on the live message, and optional + type-while-streaming keystroke latency. +- **`scripts/profile-synth-stream.mjs`** — CPU profile during a synthetic + stream; writes a `.cpuprofile` (open in Chrome DevTools Performance panel) + and a top-30 self-time table. +- **`scripts/measure-real-stream.mjs`** — same harness as the synthetic but + fires a real LLM prompt. Use when you have credits and want to confirm + the synthetic predictions hold. +- **`scripts/profile-real-stream.mjs`** — CPU profile over the duration of + a real LLM stream. + +Helpers: `scripts/eval.mjs` (one-shot CDP eval), `scripts/reload.mjs` +(hard reload renderer over CDP). + +### Findings + +Measured on the Cloud Shadows session (7 turns, ~11k px scrollHeight) and +the 34 MB session `session_20260514_215353_fe0ac8.json` (110 FadeText +instances, lots of historical tool calls). + +| metric | Cloud Shadows | 34 MB session | +|---|---|---| +| avgFps (60 tok/sec, 5s) | 60.0 | 58.6 | +| frame p50 / p95 / p99 (ms) | 16.7 / 18.0 / 21.1 | 16.6 / 25.6 / 31.4 | +| max frame (ms) | 31.1 | 97-127 (varies) | +| longtasks per 5s window | 0 | 1-2, 75-127 ms | +| type-while-stream p95 latency (ms) | 17 | — | + +A single real-LLM stream on Cloud Shadows (gpt-4o-mini, 39s window) saw +12 longtasks totalling 1.26 s — same cadence the synthetic predicted +(~1 hitch per 3.25 s, max 123 ms). So the **synthetic stream is a faithful +proxy for the real one** and is fine for iterating on fixes without paying +for tokens. + +### CPU profile during streaming (synthetic, markdown content) + +Top self-time costs (5 s window, 400 tokens at 125 tok/s, markdown chunks): + +| ms (self) | function | source | +|---|---|---| +| 260 | `bn$1` | `chunk-BO2N…js:20003` (micromark tokenize) | +| 249 | `m$1` | `chunk-BO2N…js:19949` (micromark) | +| 128 | `compile` | `chunk-BO2N…js:21884` (mdast → hast compile) | +| 73 | FadeText body | `components/ui/fade-text.tsx` | +| 62 | `parser` | `chunk-BO2N…js:22680` | +| 49 | `fromThreadMessageLike` | `@assistant-ui/internal` | + +That `chunk-BO2N2NFS` is the vendored bundle containing `micromark`, +`mdast-util-from-markdown`, `mdast-util-to-hast`, `rehype-raw`, +`hast-util-sanitize`, etc. — i.e. **Streamdown's markdown pipeline, +re-parsing the entire growing assistant message on every token append**. +Cost scales linearly with message length. + +Compare plain-text (no markdown) — the `chunk-BO2N…` entries drop out +of the top 30 entirely; total work per 5 s window halves. + +### Fix landed: `FadeText` memo + +`FadeText` is used in `tool-fallback.tsx` (110 instances on a tool-heavy +thread). Before: each parent re-render during streaming triggered a +`useEffect([children])` that forced a `scrollWidth` layout read — even +when the title text was unchanged. The `useResizeObserver` already covers +the genuine resize case, so the effect was strictly redundant. + +After: wrapped in `React.memo` with a custom comparator that compares +`children` (scalar fast-path), `className`, `fadeWidth`, and `style` +field-by-field. Verified via temporary render counter: +**122 renders during a 2 s synthetic stream vs ~11 000 without memo** +(110 instances × ~100 stream updates). Doesn't move the longtask needle +on its own — Streamdown dwarfs it — but eliminates a class of forced +layouts and removes a steady CPU floor. + +### Also landed: `MarkdownText` plugins memo + upstream flush floor + +Two smaller follow-ups in the same investigation: + +1. **`MarkdownText` `plugins` object useMemo'd.** The inline + `plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}` + was constructing a new object on every render, which churns + `<Streamdown>`'s outer memo and forces its internal `rehypePlugins` / + `remarkPlugins` arrays to rebuild. CPU profile after the change shows + `parser` self-time dropping out of the top 10, `compile` cut roughly + in half, and `bn$1` / `m$1` (micromark internals) dropping off the + top entries. + +2. **`use-message-stream.scheduleDeltaFlush` got a real minimum floor.** + Previously the rAF-only path effectively meant "at most one flush per + frame," but at typical LLM token rates of 30-80 tok/sec each token + arrives slower than rAF cadence and gets its own React commit. With + `STREAM_DELTA_FLUSH_MS = 33` (two frames) and a `lastFlushAt`-tracked + floor, slower streams now coalesce ~2 tokens per commit, halving + markdown re-parses. React's auto-batching already covers part of this + probabilistically; the floor makes the batching deterministic so the + max-longtask number tightens up. + +A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks +(3 trials each): + +| | avgFps | p99 frame | LTs/5s | max LT | mutations | +|---|---|---|---|---|---| +| no throttle | 54.0 | 38 ms | 2.0 | 145 ms | varies (2-112) | +| 33 ms throttle | 54.3 | 41 ms | 1.7 | 110 ms | ~135 | + +Modest. `inter-mutation` p50 tightens from 22-28 ms to a clean 33 ms, +which is what you'd expect from a deterministic floor. + +### Also landed: `useDeferredValue` at the streamdown-text boundary + +The longtask CPU was unavoidable inside the block-memo pattern — the live +tail re-parses every commit, scales linearly with current length, and +nothing about Streamdown's architecture changes that without forking. The +fix is to stop having that work *block* the main thread. + +`<DeferStreamingText>` in `markdown-text.tsx` is a 12-line wrapper that +reads the message-part state via `useMessagePartText`, runs it through +`useDeferredValue`, and re-publishes via assistant-ui's +`<TextMessagePartProvider>`. The inner `StreamdownTextPrimitive` reads the +deferred value through the normal `useMessagePartText` hook — no fork, +no internal-path imports, fully on the assistant-ui public API. + +What React's concurrent scheduler now does: + +- When a new token arrives mid-render, the in-flight deferred render + is abandoned and a fresh one starts with the latest text. +- When the main thread has urgent work (typing, scroll, layout), the + Streamdown render gets deprioritized — input stays responsive even + while a 100 ms parse is queued. + +Streamdown already uses `useTransition` internally for its block-array +setState; `useDeferredValue` here just lifts the deferral all the way up +to the consumer text boundary, so the whole pipeline — preprocess, +block split, repair, parse, render — runs at low priority during streaming. +This is the industry-standard approach (see +[Streamdown architecture analysis](https://tigerabrodi.blog/how-to-build-a-performant-ai-markdown-renderer) +and Chrome's [LLM-response render best practices](https://developer.chrome.google.cn/docs/ai/render-llm-responses)). + +A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks +(four trials each, prod-throttle (33 ms) on for both): + +| | avgFps | p99 frame | LTs / 5 s | max LT | typing p95 | +|---|---|---|---|---|---| +| pre-defer | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms | +| **post-defer** | **58.5** | **31 ms** | 2.0 | 117 ms | 14-18 ms | + +Longtask count and max LT are unchanged — `useDeferredValue` doesn't +reduce CPU, only its priority. The avgFps lift and p99 frame drop are +the proof that the existing CPU is no longer blocking 60 fps cadence: +when React can defer the parse, frames stay clean. One particularly +clean run logged **MUTATIONS=0** — React skipped every intermediate +text state and only committed the final one, the textbook +useDeferredValue behaviour. + +### Not fixed: Streamdown markdown re-parse cost (the elephant) + +Total CPU spent in micromark/mdast/hast pipeline per 5 s window is still +the same ~700 ms. With `useDeferredValue` that work no longer blocks +input, but if you watch a CPU profile you'll see the same hot functions +(`Tn$1`, `bn$1`, `m$1`, `parser`, `compile`). + +The path to actually *reduce* that cost (not just defer it) is to +replace the parser with a state machine like +[Flowdown](https://github.com/Atomics-hub/flowdown) — process each +character exactly once, emit DOM ops directly, no re-parse of the prefix +on every token. Claimed ~2,000× over `marked`. Trades: not a +`react-markdown`-compatible API, no rehype security pipeline, would +require replacing Streamdown wholesale. Worth investigating only if +even the deferred work shows up in user-perceptible ways (e.g. +trackpad-scrolling a stream-in-progress stutters). + +The synthetic harness now mirrors the real upstream pipeline via the +`flushMinMs` option in `__PERF_DRIVE__.stream({ flushMinMs: 33 })`, so +future Streamdown / Flowdown experiments can A/B without LLM credit cost. +The synthetic numbers tracked the one real-LLM run we caught within +noise, so it's a reliable proxy. + +Possible approaches (none implemented here): + +1. **Coalesce/throttle Streamdown updates** — render at most every 32 ms + instead of every set-state. Reduces parses but doesn't reduce + per-parse cost; trades latency for smoothness. +2. **Memoize per-prefix** — diff the new text against the prior parsed + version; only re-parse the changed suffix. +3. **Render in stable segments** — close-form historical paragraphs as + immutable React nodes; only the live tail goes through markdown each + token. Probably the highest-impact change but requires forking or + patching `@assistant-ui/react-streamdown`. +4. **Move parsing to a Web Worker** — main thread no longer blocks on + markdown. Largest surgery; requires double-buffered hast. + +### Vite dev-build issue (separate) + +`http://127.0.0.1:5174/node_modules/.vite/deps/react.js` resolves to +`react/cjs/react.production.js`, and `react-dom_client.js` → +`react-dom-client.production.js`. As a result: + +- `<React.Profiler>` `onRender` is never called (production build is a + no-op). +- `import.meta.env.DEV` is `false`, `PROD` is `true` even under `vite dev` + (hence `MODE !== 'production'` as the workaround in `main.tsx`). +- All the React 19 dev-only warnings/devtools backend hooks are absent. + +Root cause likely sits in `vite.config.ts` aliasing + dedupe + Vite 8's +new `optimizeDeps` defaults. Worth a separate fix pass — when it's +resolved, the `<PerfProbe>` blocks in `perf-probe.tsx` become useful +(per-id commit timings) instead of inert. diff --git a/apps/desktop/scripts/profile-typing.mjs b/apps/desktop/scripts/profile-typing.mjs new file mode 100644 index 000000000..f57cb40ad --- /dev/null +++ b/apps/desktop/scripts/profile-typing.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node +// Profile typing lag in the Electron renderer by: +// 1. Connecting to a running renderer via CDP (--remote-debugging-port=9222) +// 2. Focusing the composer contentEditable +// 3. Starting CPU profile + heap snapshot +// 4. Synthesizing keystrokes via Input.dispatchKeyEvent (so the run is +// reproducible, no human-typing variance) +// 5. Stopping the profile + capturing a second heap snapshot +// 6. Saving .cpuprofile + .heapsnapshot +// +// Usage: +// node apps/desktop/scripts/profile-typing.mjs +// [--port=9222] [--out=/tmp/hermes-typing] +// [--chars=400] # how many characters to type +// [--cps=30] # keystrokes per second +// [--text="..."] # override generated text +// [--no-heap] # skip heap snapshots +// [--seconds=N] # idle-record for N seconds instead of typing +// +// Zero deps — uses Node 24's global WebSocket + fetch. + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) + +const PORT = Number(args.port ?? 9222) +const OUT = String(args.out ?? `/tmp/hermes-typing-${Date.now()}`) +const CHARS = Number(args.chars ?? 400) +const CPS = Number(args.cps ?? 30) +const HEAP = args['no-heap'] ? false : true +const IDLE_SECONDS = args.seconds ? Number(args.seconds) : null +const CUSTOM_TEXT = args.text === undefined || args.text === true ? null : String(args.text) + +const log = (...m) => console.log('[profile]', ...m) +const banner = m => console.log(`\n========== ${m} ==========`) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + const pages = list.filter(t => t.type === 'page' && t.url.startsWith('http')) + if (!pages.length) { + console.error('No renderer page. Targets:') + list.forEach(t => console.error(' ', t.type, t.url)) + process.exit(2) + } + return pages[0] +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + const events = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + on(method, h) { + if (!events.has(method)) events.set(method, []) + events.get(method).push(h) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const txt = typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8') + const m = JSON.parse(txt) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } else if (m.method) { + ;(events.get(m.method) ?? []).forEach(h => h(m.params)) + } + }) + }) +} + +async function captureHeap(cdp, path) { + log(`heap snapshot → ${path}`) + const chunks = [] + cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => chunks.push(chunk)) + await cdp.send('HeapProfiler.enable') + await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false, captureNumericValue: true }) + writeFileSync(path, chunks.join('')) + log(` ${(Buffer.byteLength(chunks.join(''), 'utf8') / 1024 / 1024).toFixed(1)} MB`) +} + +async function focusComposer(cdp) { + // Focus the rich-input contentEditable. RICH_INPUT_SLOT is the data-slot + // value used by the composer's editable div. If focus fails (no composer + // mounted yet — disabled state, etc.) the script logs and continues; the + // profile will still show idle behavior. + const result = await cdp.send('Runtime.evaluate', { + expression: ` + (() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return { ok: false, reason: 'composer-rich-input not found' } + el.focus() + // place caret at end + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + return { ok: true, text: el.innerText.length } + })() + `, + returnByValue: true + }) + if (!result.result.value?.ok) { + log(`focus failed: ${result.result.value?.reason ?? 'unknown'}`) + return false + } + log(`composer focused (existing text length: ${result.result.value.text})`) + return true +} + +function genText(n) { + const lorem = + 'the quick brown fox jumps over the lazy dog while the agent thinks really hard about why typing into this composer feels like wading through molasses on a hot afternoon ' + let s = '' + while (s.length < n) s += lorem + return s.slice(0, n) +} + +async function dispatchChar(cdp, ch) { + // For printable chars, char + keypress is enough — Electron treats it as text input + // and the contentEditable input event fires. For Enter / Space we could add + // specials; this run is one long line. + await cdp.send('Input.dispatchKeyEvent', { + type: 'char', + text: ch, + unmodifiedText: ch + }) +} + +async function typeText(cdp, text, cps) { + const intervalMs = Math.max(1, Math.round(1000 / cps)) + const start = Date.now() + for (let i = 0; i < text.length; i++) { + await dispatchChar(cdp, text[i]) + // Pace evenly; account for dispatch latency so we don't drift much. + const expected = start + (i + 1) * intervalMs + const wait = expected - Date.now() + if (wait > 0) await new Promise(r => setTimeout(r, wait)) + } +} + +async function main() { + log(`CDP port ${PORT}, out ${OUT}`) + const target = await pickRenderer() + log(`target ${target.url}`) + const cdp = await connect(target.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + await cdp.send('Page.enable') + await cdp.send('Profiler.enable') + + // Pre-GC so the cpu profile + heap delta are clean. + try { + await cdp.send('HeapProfiler.collectGarbage') + } catch (e) { + log('GC skipped:', e.message) + } + + if (HEAP) await captureHeap(cdp, `${OUT}.before.heapsnapshot`) + + // 1ms sampling — fine enough for per-frame React work. + await cdp.send('Profiler.setSamplingInterval', { interval: 1000 }) + + let typedText = '' + if (!IDLE_SECONDS) { + const focused = await focusComposer(cdp) + if (!focused) { + log('aborting — composer not focusable. Make sure the app is past the boot screen.') + cdp.close() + process.exit(3) + } + typedText = CUSTOM_TEXT ?? genText(CHARS) + } + + await cdp.send('Profiler.start') + + if (IDLE_SECONDS) { + banner(`IDLE recording for ${IDLE_SECONDS}s — DO NOT TOUCH`) + await new Promise(r => setTimeout(r, IDLE_SECONDS * 1000)) + } else { + banner(`TYPING ${typedText.length} chars @ ${CPS} cps (≈${(typedText.length / CPS).toFixed(1)}s)`) + const t0 = Date.now() + await typeText(cdp, typedText, CPS) + log(`typing wall time: ${((Date.now() - t0) / 1000).toFixed(2)}s`) + // Settle frame for trailing React work. + await new Promise(r => setTimeout(r, 500)) + } + + banner('STOP — saving profile') + const { profile } = await cdp.send('Profiler.stop') + writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile)) + log(`cpu profile → ${OUT}.cpuprofile (${(JSON.stringify(profile).length / 1024 / 1024).toFixed(1)} MB)`) + + if (HEAP) { + try { + await cdp.send('HeapProfiler.collectGarbage') + } catch {} + await captureHeap(cdp, `${OUT}.after.heapsnapshot`) + } + + // Quick triage: top-self-time frames from the profile. + const top = summarizeProfile(profile) + banner('TOP SELF-TIME FRAMES') + for (const row of top.slice(0, 20)) { + console.log( + ` ${row.selfMs.toFixed(1).padStart(7)}ms ${row.functionName || '(anonymous)'}` + + ` ${row.url ? '· ' + row.url.replace(/^.*\/src\//, 'src/').slice(0, 80) : ''}` + ) + } + console.log() + log(`total samples: ${top.totalSamples}, total time: ${(top.totalMs / 1000).toFixed(2)}s`) + + cdp.close() +} + +function summarizeProfile(profile) { + // Cumulative samples = how many sampling ticks landed on each node. + // selfMs = own time only, using sampling interval. + const intervalMs = (profile.endTime - profile.startTime) / 1000 / Math.max(1, profile.samples?.length ?? 1) + const counts = new Map() + for (const s of profile.samples ?? []) counts.set(s, (counts.get(s) ?? 0) + 1) + const rows = profile.nodes.map(n => { + const self = counts.get(n.id) ?? 0 + return { + id: n.id, + functionName: n.callFrame.functionName, + url: n.callFrame.url, + lineNumber: n.callFrame.lineNumber, + selfSamples: self, + selfMs: self * intervalMs + } + }) + rows.sort((a, b) => b.selfSamples - a.selfSamples) + rows.totalSamples = (profile.samples ?? []).length + rows.totalMs = ((profile.endTime - profile.startTime) / 1000) + return rows +} + +main().catch(e => { + console.error('[profile] fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/reload-renderer.mjs b/apps/desktop/scripts/reload-renderer.mjs new file mode 100644 index 000000000..f1f57462d --- /dev/null +++ b/apps/desktop/scripts/reload-renderer.mjs @@ -0,0 +1,25 @@ +// Reload the renderer via CDP so it picks up the latest from Vite. +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (method, params = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method, params })) + }) +await send('Page.enable') +await send('Page.reload', { ignoreCache: true }) +console.log('reload requested') +await new Promise(r => setTimeout(r, 200)) +ws.close() diff --git a/apps/desktop/scripts/reload.mjs b/apps/desktop/scripts/reload.mjs new file mode 100644 index 000000000..b5f768473 --- /dev/null +++ b/apps/desktop/scripts/reload.mjs @@ -0,0 +1,36 @@ +// Hard reload the Electron renderer over CDP. Vite-no-HMR mode means edits +// don't auto-apply — call this after editing source. +const targets = await (await fetch('http://127.0.0.1:9222/json')).json() +const t = targets.find((t) => t.url.includes('5174')) +if (!t) { + console.error('renderer not found') + process.exit(1) +} +const ws = new WebSocket(t.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', (ev) => { + const m = JSON.parse(ev.data) + if (pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise((r) => ws.addEventListener('open', r)) +const send = (method, params = {}) => + new Promise((res) => { + const i = ++id + pending.set(i, res) + ws.send(JSON.stringify({ id: i, method, params })) + }) + +await send('Page.reload', { ignoreCache: true }) +console.log('reload sent') +// Wait for new doc. +await new Promise((r) => setTimeout(r, 2500)) +const r = await send('Runtime.evaluate', { + expression: 'JSON.stringify({ hasProbe: !!window.__PERF_PROBE__, composer: !!document.querySelector("[contenteditable=true]"), url: location.hash })', + returnByValue: true, +}) +console.log(r.result.result.value) +ws.close() diff --git a/apps/desktop/scripts/set-exe-identity.cjs b/apps/desktop/scripts/set-exe-identity.cjs new file mode 100644 index 000000000..129e1505b --- /dev/null +++ b/apps/desktop/scripts/set-exe-identity.cjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the +// built Hermes.exe using rcedit, completely decoupled from electron-builder's +// signing path. +// +// WHY THIS EXISTS +// --------------- +// apps/desktop/package.json sets build.win.signAndEditExecutable=false. That +// flag is load-bearing: turning electron-builder's own exe-editing ON also +// re-enables its signtool step, which fetches winCodeSign-2.6.0.7z, whose +// macOS symlinks crash 7-Zip on non-admin Windows (no Developer Mode = no +// SeCreateSymbolicLinkPrivilege). That is an unfixable dead end — we do NOT +// try to extract winCodeSign. +// +// The cost of disabling signAndEditExecutable is that electron-builder also +// skips rcedit, so the unpacked Hermes.exe keeps the stock Electron icon and +// "Electron" taskbar name. This script restores the icon + identity by calling +// rcedit DIRECTLY. rcedit is a pure PE resource editor: no signing, no certs, +// no winCodeSign, no symlinks. +// +// HOW IT RUNS +// ----------- +// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs), +// so EVERY packed build — first install, `hermes desktop`, the installer's +// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from +// one place. Previously this stamp lived only in install.ps1, so the update +// path (which rebuilds via `hermes desktop --build-only`, never install.ps1) +// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap. +// +// Also runnable standalone for ad-hoc re-stamping: +// node scripts/set-exe-identity.cjs <path-to-Hermes.exe> +// +// Exits 0 on success, non-zero on failure when run as a CLI. As a hook, +// stampExeIdentity() resolves on success and rejects on failure; the caller +// (after-pack.cjs) swallows the rejection so a stamp failure never fails an +// otherwise-good build (worst case: stock icon, not a broken app). + +const path = require('node:path') +const fs = require('node:fs') + +// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on +// failure. `desktopRoot` defaults to this script's package root so the icon and +// the rcedit dependency resolve regardless of cwd. +async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) { + if (!exe || !fs.existsSync(exe)) { + throw new Error(`target exe not found: ${exe}`) + } + + // Icon lives at apps/desktop/assets/icon.ico + const icon = path.join(desktopRoot, 'assets', 'icon.ico') + if (!fs.existsSync(icon)) { + throw new Error(`icon not found: ${icon}`) + } + + // rcedit is a direct devDependency of apps/desktop, so it resolves whether + // we're run from the desktop dir or the repo root (workspace hoist). + // rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a + // default export. + const mod = require('rcedit') + const rcedit = typeof mod === 'function' ? mod : mod.rcedit + if (typeof rcedit !== 'function') { + throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`) + } + + console.log(`[set-exe-identity] stamping ${exe}`) + console.log(`[set-exe-identity] icon: ${icon}`) + + await rcedit(exe, { + icon, + 'version-string': { + ProductName: 'Hermes', + FileDescription: 'Hermes', + CompanyName: 'Nous Research', + LegalCopyright: 'Copyright (c) 2026 Nous Research' + } + }) + + console.log('[set-exe-identity] done — Hermes icon + identity stamped') +} + +module.exports = { stampExeIdentity } + +// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`. +if (require.main === module) { + const exe = process.argv[2] + if (!exe) { + console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>') + process.exit(2) + } + stampExeIdentity(exe).catch(err => { + console.error(`[set-exe-identity] ${err.message}`) + process.exit(1) + }) +} diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs new file mode 100644 index 000000000..d84ae2cf5 --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.cjs @@ -0,0 +1,159 @@ +'use strict' + +/** + * Stage native node-modules dependencies for electron-builder packaging. + * + * Workspace dedup hoists `node-pty` into the root `node_modules/`, which + * electron-builder's default file collector (when `files:` is explicitly set + * in package.json) cannot reach. The result: packaged builds ship with no + * .node binaries and PTY initialization fails at runtime ("PTY support is + * unavailable"). + * + * Rather than restructure the workspace dedup (would require nohoist / + * package.json shenanigans and risk breaking dev) or balloon the package + * with the whole node_modules tree, we copy ONLY the runtime-essential + * files of the native dep into apps/desktop/build/native-deps/ and ship + * THAT subtree via extraResources. main.cjs falls back to require()-ing + * from process.resourcesPath when the hoisted-root require fails. + * + * Runs as part of `npm run build`. Idempotent -- always re-stages on each + * build to pick up native binary updates. + * + * Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based + * and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of + * `build/Release/`. Its runtime resolver (lib/utils.js) checks + * build/Release first and falls through to the per-arch prebuilds dir, so + * shipping only the latter is sufficient for packaged runs. Per-arch + * staging keeps the resource bundle lean -- we only need the target + * arch's prebuilt, not all of them. + */ + +const fs = require('node:fs') +const path = require('node:path') + +const APP_ROOT = path.resolve(__dirname, '..') +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..') +const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps') + +// The target arch may be overridden by electron-builder via npm_config_arch +// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch. +const TARGET_ARCH = process.env.npm_config_arch || process.arch +const TARGET_PLATFORM = process.platform + +// Modules to stage. The "from" path is the hoisted location in the workspace +// root; "to" is the layout we want inside build/native-deps/. The "include" +// globs (relative to "from") select the runtime-essential files. Anything +// outside the include list is left behind (source, deps/, scripts/, etc.). +const NATIVE_DEPS = [ + { + from: path.join(REPO_ROOT, 'node_modules', 'node-pty'), + to: path.join(STAGE_ROOT, 'node-pty'), + include: [ + 'package.json', + 'lib/*.js', + 'lib/**/*.js', + 'build/Release/*.node', + // Per-arch runtime payload. Explicit file types so we don't ship the + // ~25 MB of .pdb debug symbols that prebuild-install bundles for + // Windows crash analysis -- not used at runtime, would just bloat + // the installer. + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`, + `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*` + ] + } +] + +function rmrf(target) { + fs.rmSync(target, { recursive: true, force: true }) +} + +function ensureDir(target) { + fs.mkdirSync(target, { recursive: true }) +} + +function walk(root) { + const results = [] + const stack = [root] + while (stack.length) { + const current = stack.pop() + let entries + try { + entries = fs.readdirSync(current, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const full = path.join(current, entry.name) + if (entry.isDirectory()) { + stack.push(full) + } else if (entry.isFile()) { + results.push(full) + } + } + } + return results +} + +// Match a relative path against simple ** and * glob patterns. Implementation +// is intentionally tiny -- the include lists are small and don't need full +// minimatch support. +function matchGlob(rel, pattern) { + const r = rel.replace(/\\/g, '/') + const re = new RegExp( + '^' + + pattern + .replace(/\\/g, '/') + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '__DOUBLE_STAR__') + .replace(/\*/g, '[^/]*') + .replace(/__DOUBLE_STAR__/g, '.*') + + '$' + ) + return re.test(r) +} + +function stageOne(spec) { + if (!fs.existsSync(spec.from)) { + throw new Error( + `stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` + + `at the workspace root first.` + ) + } + rmrf(spec.to) + ensureDir(spec.to) + + const files = walk(spec.from) + let copied = 0 + for (const abs of files) { + const rel = path.relative(spec.from, abs) + const included = spec.include.some(g => matchGlob(rel, g)) + if (!included) continue + const dest = path.join(spec.to, rel) + ensureDir(path.dirname(dest)) + fs.copyFileSync(abs, dest) + // node-pty's darwin spawn-helper and the Windows helper binaries + // (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn / + // CreateProcess at runtime, so they must remain executable in the + // staged tree. fs.copyFileSync preserves source mode on POSIX, but we + // re-assert +x defensively for the darwin spawn-helper (no extension + // means a stripped mode would be silently broken at runtime). + if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') { + try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ } + } + copied += 1 + } + console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`) +} + +function main() { + rmrf(STAGE_ROOT) + ensureDir(STAGE_ROOT) + for (const spec of NATIVE_DEPS) { + stageOne(spec) + } +} + +main() diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs new file mode 100644 index 000000000..fdff1523f --- /dev/null +++ b/apps/desktop/scripts/test-desktop.mjs @@ -0,0 +1,425 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawn, spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { listPackage } from '@electron/asar' + +const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8')) +const MODE = process.argv[2] || 'help' +const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64' +const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') +const PLATFORM = process.platform + +// Platform-specific packaged-app layout. The thin installer ships an Electron +// app shell plus extraResources (install-stamp.json + native-deps/) -- it +// no longer bundles the Hermes Agent Python payload (that's fetched at first +// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow). +const APP = (() => { + if (PLATFORM === 'darwin') { + const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app') + return { + appPath, + binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'), + resourcesPath: path.join(appPath, 'Contents', 'Resources'), + asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'), + unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html') + } + } + if (PLATFORM === 'win32') { + const unpacked = path.join(RELEASE_ROOT, 'win-unpacked') + return { + appPath: unpacked, + binary: path.join(unpacked, 'Hermes.exe'), + resourcesPath: path.join(unpacked, 'resources'), + asarPath: path.join(unpacked, 'resources', 'app.asar'), + unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') + } + } + // linux unpacked layout matches windows but with different binary name + const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked') + return { + appPath: unpacked, + binary: path.join(unpacked, 'hermes'), + resourcesPath: path.join(unpacked, 'resources'), + asarPath: path.join(unpacked, 'resources', 'app.asar'), + unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') + } +})() + +// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's +// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere +// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own +// HERMES_HOME and never touches this. +const DEFAULT_HERMES_HOME = (() => { + if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) { + return path.join(process.env.LOCALAPPDATA, 'hermes') + } + return path.join(os.homedir(), '.hermes') +})() +const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv') +const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install') + +function die(message) { + console.error(`\n${message}`) + process.exit(1) +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || DESKTOP_ROOT, + env: options.env || process.env, + shell: Boolean(options.shell) || PLATFORM === 'win32', + stdio: 'inherit' + }) + + if (result.status !== 0) { + die(`${command} ${args.join(' ')} failed`) + } +} + +function exists(target) { + return fs.existsSync(target) +} + +// Match nodepty native binding location to what main.cjs's resolver fallback +// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream +// node-pty 1.x is N-API based and ships per-arch prebuilts under +// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the +// per-arch dir since that's what stage-native-deps actually copies. +function expectedNativeDepPaths() { + const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty') + const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`) + return { + packageJson: path.join(root, 'package.json'), + prebuildsDir, + libIndex: path.join(root, 'lib', 'index.js') + } +} + +function ensurePlatformBuilds() { + if (PLATFORM === 'darwin') return + if (PLATFORM === 'win32') return + die( + `Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` + + `is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` + + `install.sh's stage protocol.` + ) +} + +function ensurePackagedApp() { + if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP.binary)) { + return + } + + run('npm', ['run', 'pack']) +} + +function resolveDmgPath() { + if (!exists(RELEASE_ROOT)) { + return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`) + } + + const prefix = `Hermes-${PACKAGE_JSON.version}` + const candidates = fs + .readdirSync(RELEASE_ROOT) + .filter(name => name.endsWith('.dmg')) + .filter(name => name.startsWith(prefix)) + .filter(name => name.includes(ARCH)) + .sort((a, b) => { + const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs + const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs + return bMtime - aMtime + }) + + return candidates.length > 0 + ? path.join(RELEASE_ROOT, candidates[0]) + : path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`) +} + +function resolveNsisPath() { + // electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}' + if (!exists(RELEASE_ROOT)) return null + const candidates = fs + .readdirSync(RELEASE_ROOT) + .filter(name => /\.exe$/i.test(name) && /win/i.test(name)) + .sort((a, b) => { + const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs + const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs + return bMtime - aMtime + }) + return candidates.length > 0 ? path.join(RELEASE_ROOT, candidates[0]) : null +} + +function ensureDmg() { + if (PLATFORM !== 'darwin') { + die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.') + } + if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) { + return + } + run('npm', ['run', 'dist:mac:dmg']) +} + +function ensureNsis() { + if (PLATFORM !== 'win32') { + die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.') + } + if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) { + return + } + run('npm', ['run', 'dist:win:nsis']) +} + +function openApp() { + if (!exists(APP.binary)) { + die(`Missing packaged app: ${APP.binary}`) + } + + if (PLATFORM === 'darwin') { + run('open', ['-n', APP.appPath]) + } else if (PLATFORM === 'win32') { + // Spawn detached so the test script exits while the app keeps running. + spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref() + } else { + spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref() + } +} + +function openDmg() { + if (PLATFORM !== 'darwin') { + die('DMG mode is macOS-only.') + } + const dmgPath = resolveDmgPath() + if (!exists(dmgPath)) { + die(`Missing DMG: ${dmgPath}`) + } + run('open', [dmgPath]) +} + +const CREDENTIAL_ENV_SUFFIXES = [ + '_API_KEY', + '_TOKEN', + '_SECRET', + '_PASSWORD', + '_CREDENTIALS', + '_ACCESS_KEY', + '_PRIVATE_KEY', + '_OAUTH_TOKEN' +] + +const CREDENTIAL_ENV_NAMES = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_TOKEN', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'CUSTOM_API_KEY', + 'GEMINI_BASE_URL', + 'OPENAI_BASE_URL', + 'OPENROUTER_BASE_URL', + 'OLLAMA_BASE_URL', + 'GROQ_BASE_URL', + 'XAI_BASE_URL' +]) + +function isCredentialEnvVar(name) { + if (CREDENTIAL_ENV_NAMES.has(name)) return true + return CREDENTIAL_ENV_SUFFIXES.some(suffix => name.endsWith(suffix)) +} + +function launchFresh() { + if (!exists(APP.binary)) { + die(`Missing app executable: ${APP.binary}`) + } + + const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`) + const userDataDir = path.join(sandbox, 'electron-user-data') + const hermesHome = path.join(sandbox, 'hermes-home') + const cwd = path.join(sandbox, 'workspace') + + fs.mkdirSync(userDataDir, { recursive: true }) + fs.mkdirSync(hermesHome, { recursive: true }) + fs.mkdirSync(cwd, { recursive: true }) + + // Strip every credential-shaped env var so the sandbox is actually fresh. + const env = {} + for (const [key, value] of Object.entries(process.env)) { + if (isCredentialEnvVar(key)) continue + env[key] = value + } + + env.HERMES_DESKTOP_CWD = cwd + env.HERMES_DESKTOP_IGNORE_EXISTING = '1' + env.HERMES_DESKTOP_TEST_MODE = 'fresh-install' + env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir + env.HERMES_HOME = hermesHome + delete env.HERMES_DESKTOP_HERMES + delete env.HERMES_DESKTOP_HERMES_ROOT + + const child = spawn(APP.binary, [], { + cwd: os.homedir(), + detached: true, + env, + stdio: 'ignore' + }) + child.unref() + + console.log('\nFresh install sandbox:') + console.log(` root: ${sandbox}`) + console.log(` electron userData: ${userDataDir}`) + console.log(` HERMES_HOME: ${hermesHome}`) + console.log(` cwd: ${cwd}`) + + return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') } +} + +// Validate the packaged bundle matches the thin-installer architecture: +// - The Hermes Agent Python payload is NOT shipped (it's fetched at first +// launch via install.ps1's stage protocol). +// - install-stamp.json IS shipped in resources/ with a valid commit + branch. +// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with +// the package.json + lib/ + at least one .node binary (the renderer's +// integrated terminal needs this; see Phase 1F.6). +// - The renderer's dist/index.html is reachable (either unpacked or +// inside app.asar). +function validateBundle() { + if (!exists(APP.binary)) { + die(`Missing packaged app binary: ${APP.binary}`) + } + + // Negative assertion: the OLD fat-installer factory payload must NOT be + // present anymore. If a stray ship of hermes_cli sneaks back in we want + // to fail loudly rather than re-introduce the 400MB delta we just removed. + const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py') + if (exists(staleFactoryMarker)) { + die( + `Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}` + ) + } + + // Positive assertion: install-stamp.json carries a sane commit + branch + const stampPath = path.join(APP.resourcesPath, 'install-stamp.json') + if (!exists(stampPath)) { + die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`) + } + let stamp + try { + stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8')) + } catch (err) { + die(`install-stamp.json is not valid JSON: ${err.message}`) + } + if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) { + die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`) + } + if (!stamp.branch || typeof stamp.branch !== 'string') { + die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`) + } + + // Positive assertion: node-pty native deps shipped + const native = expectedNativeDepPaths() + if (!exists(native.packageJson)) { + die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`) + } + if (!exists(native.libIndex)) { + die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`) + } + if (!exists(native.prebuildsDir)) { + die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`) + } + const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node')) + if (nodeBinaries.length === 0) { + die(`No .node native binaries found in: ${native.prebuildsDir}`) + } + // Darwin requires a runtime-execed spawn-helper alongside pty.node; missing + // it manifests as "ENOENT: spawn-helper" on first pty.spawn() call. + if (PLATFORM === 'darwin') { + const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper') + if (!exists(spawnHelper)) { + die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`) + } + } + + // Renderer payload check (either unpacked or in the asar) + if (exists(APP.unpackedDistIndex)) { + return { stamp, nodeBinaries } + } + if (!exists(APP.asarPath)) { + die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`) + } + const files = listPackage(APP.asarPath) + // Normalize separators because @electron/asar's listPackage returns + // backslash-prefixed entries on Windows ('\\dist\\index.html') and + // forward-slash on Unix. + const normalized = files.map(f => f.replace(/\\/g, '/').replace(/^\/+/, '')) + if (!normalized.includes('dist/index.html')) { + die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`) + } + return { stamp, nodeBinaries } +} + +function printArtifacts(options = {}) { + const runtimeRoot = options.runtimeRoot || VENV_ROOT + const stamp = options.stamp + + console.log('\nDesktop artifacts:') + console.log(` app: ${APP.appPath}`) + if (PLATFORM === 'darwin') { + console.log(` dmg: ${resolveDmgPath()}`) + } else if (PLATFORM === 'win32') { + const exe = resolveNsisPath() + if (exe) console.log(` installer: ${exe}`) + } + console.log(` runtime: ${runtimeRoot}`) + if (stamp) { + console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`) + } + if (options.nodeBinaries && options.nodeBinaries.length > 0) { + console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`) + } +} + +function help() { + console.log(`Usage: + npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes + npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME + npm run test:desktop:dmg # (macOS only) build DMG and open it + npm run test:desktop:nsis # (win32 only) build NSIS installer + npm run test:desktop:all # build installer, validate app payload, print paths + +Fast rerun (skip rebuild if the packaged app already exists): + HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh +`) +} + +ensurePlatformBuilds() + +if (MODE === 'existing') { + ensurePackagedApp() + const result = validateBundle() + openApp() + printArtifacts(result) +} else if (MODE === 'fresh') { + ensurePackagedApp() + const result = validateBundle() + printArtifacts({ ...launchFresh(), ...result }) +} else if (MODE === 'dmg') { + ensureDmg() + openDmg() + printArtifacts() +} else if (MODE === 'nsis') { + ensureNsis() + printArtifacts(validateBundle()) +} else if (MODE === 'all') { + if (PLATFORM === 'darwin') { + ensureDmg() + } else if (PLATFORM === 'win32') { + ensureNsis() + } else { + ensurePackagedApp() + } + printArtifacts(validateBundle()) +} else { + help() +} diff --git a/apps/desktop/scripts/write-build-stamp.cjs b/apps/desktop/scripts/write-build-stamp.cjs new file mode 100644 index 000000000..72b978c5f --- /dev/null +++ b/apps/desktop/scripts/write-build-stamp.cjs @@ -0,0 +1,126 @@ +"use strict" + +/** + * Writes apps/desktop/build/install-stamp.json with the git ref the desktop + * .exe should pin to at first-launch bootstrap time. This file ships inside + * the packaged app via electron-builder's extraResources entry and is read + * by electron/main.cjs to drive the install.ps1 stage bootstrap flow. + * + * Schema (subject to bump via STAMP_SCHEMA_VERSION): + * { + * "schemaVersion": 1, + * "commit": "<40-char SHA>", + * "branch": "<branch name>", + * "builtAt": "<ISO 8601 UTC timestamp>", + * "dirty": true|false, + * "source": "ci" | "local" + * } + * + * Source preference order: + * 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with + * shallow clones, detached HEADs, etc. in CI. + * 2. Local `git rev-parse` against the parent repo (../..). + * + * Dev / out-of-repo builds without git produce an explicit error rather than + * silently writing an unstamped manifest -- the packaged app refuses to + * bootstrap without a stamp. + */ + +const fs = require("fs") +const path = require("path") +const { execSync } = require("child_process") + +const STAMP_SCHEMA_VERSION = 1 + +const DESKTOP_ROOT = path.resolve(__dirname, "..") +const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..") +const OUT_DIR = path.join(DESKTOP_ROOT, "build") +const OUT_FILE = path.join(OUT_DIR, "install-stamp.json") + +function tryExec(cmd, opts) { + try { + return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim() + } catch { + return null + } +} + +function fromCI() { + const sha = process.env.GITHUB_SHA + if (!sha) return null + const branch = process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || null + return { + commit: sha, + branch: branch, + dirty: false, // CI builds from a checkout-of-ref by definition + source: "ci" + } +} + +function fromLocalGit() { + const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT }) + if (!sha) return null + const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT }) + // `git status --porcelain -uno` is empty iff tracked files match HEAD. + // We exclude untracked files (-uno) intentionally: a developer who's + // checked out an installer scratch dir alongside the repo shouldn't + // poison every local build with a [DIRTY] stamp. We DO care about + // tracked-but-modified files because those mean the .exe content + // differs from the commit being pinned. + const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT }) + const dirty = status !== null && status.length > 0 + return { + commit: sha, + branch: branch === "HEAD" ? null : branch, // detached HEAD -> null + dirty: dirty, + source: "local" + } +} + +function main() { + const stamp = fromCI() || fromLocalGit() + if (!stamp || !stamp.commit) { + console.error( + "[write-build-stamp] ERROR: could not determine git commit.\n" + + " - $GITHUB_SHA not set\n" + + " - `git rev-parse HEAD` failed at " + + REPO_ROOT + + "\n" + + "Packaged builds require a git ref to pin first-launch install.ps1\n" + + "against. Run from a git checkout or set $GITHUB_SHA explicitly." + ) + process.exit(1) + } + + if (stamp.dirty) { + console.warn( + "[write-build-stamp] WARNING: working tree is dirty.\n" + + " Pinning to " + + stamp.commit.slice(0, 12) + + " but the packaged code may differ from that commit.\n" + + " Commit your changes before publishing this build." + ) + } + + const payload = { + schemaVersion: STAMP_SCHEMA_VERSION, + commit: stamp.commit, + branch: stamp.branch, + builtAt: new Date().toISOString(), + dirty: stamp.dirty, + source: stamp.source + } + + fs.mkdirSync(OUT_DIR, { recursive: true }) + fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") + console.log( + "[write-build-stamp] wrote " + + path.relative(REPO_ROOT, OUT_FILE) + + " -> " + + stamp.commit.slice(0, 12) + + (stamp.branch ? " (" + stamp.branch + ")" : "") + + (stamp.dirty ? " [DIRTY]" : "") + ) +} + +main() diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx new file mode 100644 index 000000000..4bcf76e46 --- /dev/null +++ b/apps/desktop/src/app/agents/index.tsx @@ -0,0 +1,392 @@ +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect, useMemo, useState } from 'react' + +import { useElapsedSeconds } from '@/components/chat/activity-timer' +import { ActivityTimerText } from '@/components/chat/activity-timer-text' +import { BrailleSpinner } from '@/components/ui/braille-spinner' +import { FadeText } from '@/components/ui/fade-text' +import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' +import { useEnterAnimation } from '@/lib/use-enter-animation' +import { cn } from '@/lib/utils' +import { $activeSessionId } from '@/store/session' +import { + $subagentsBySession, + buildSubagentTree, + type SubagentNode, + type SubagentStatus, + type SubagentStreamEntry +} from '@/store/subagents' + +import { OverlayView } from '../overlays/overlay-view' + +// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the +// same visual vocabulary as the chat tool blocks. +function statusGlyph(status: SubagentStatus): ReactNode { + if (status === 'running' || status === 'queued') { + return ( + <BrailleSpinner + ariaLabel="Running" + className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80" + spinner="breathe" + /> + ) + } + + if (status === 'failed' || status === 'interrupted') { + return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" /> + } + + return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" /> +} + +const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = { + progress: 'text-muted-foreground/75', + summary: 'text-foreground/85', + thinking: 'text-muted-foreground/80', + tool: 'text-foreground/85' +} + +function streamGlyph(entry: SubagentStreamEntry): ReactNode { + if (entry.isError) { + return <AlertCircle aria-hidden className="mt-0.5 size-3 shrink-0 text-destructive" /> + } + + if (entry.kind === 'tool') { + return <span aria-hidden className="mt-0.5 size-1.5 shrink-0 rounded-full bg-foreground/55" /> + } + + if (entry.kind === 'summary') { + return <CheckCircle2 aria-hidden className="mt-0.5 size-3 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" /> + } + + if (entry.kind === 'thinking') { + return ( + <span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70"> + … + </span> + ) + } + + return <span aria-hidden className="mt-0.5 size-1 shrink-0 rounded-full bg-muted-foreground/55" /> +} + +interface AgentsViewProps { + onClose: () => void +} + +export function AgentsView({ onClose }: AgentsViewProps) { + const activeSessionId = useStore($activeSessionId) + const subagentsBySession = useStore($subagentsBySession) + + const activeSubagents = useMemo( + () => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []), + [activeSessionId, subagentsBySession] + ) + + const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents]) + + return ( + <OverlayView + closeLabel="Close agents" + contentClassName="px-5 pt-5 pb-4 sm:px-6" + onClose={onClose} + rootClassName="mx-auto max-w-3xl" + > + <header className="mb-3 shrink-0"> + <h2 className="text-sm font-semibold text-foreground">Spawn tree</h2> + <p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p> + </header> + <SubagentTree tree={tree} /> + </OverlayView> + ) +} + +const fmtDuration = (seconds?: number) => { + if (!seconds || seconds <= 0) { + return '' + } + + if (seconds < 60) { + return `${seconds.toFixed(1)}s` + } + + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + + return `${m}m ${s}s` +} + +const fmtTokens = (value?: number) => { + if (!value) { + return '' + } + + return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok` +} + +const fmtAge = (updatedAt: number, nowMs: number) => { + const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000)) + + if (s < 2) { + return 'now' + } + + if (s < 60) { + return `${s}s ago` + } + + const m = Math.floor(s / 60) + + if (m < 60) { + return `${m}m ago` + } + + return `${Math.floor(m / 60)}h ago` +} + +const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => + nodes.flatMap(node => [node, ...flatten(node.children)]) + +interface RootGroup { + id: string + label: string + nodes: SubagentNode[] + taskCount: number +} + +function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] { + const groups: RootGroup[] = [] + let n = 0 + + for (const node of roots) { + const prev = groups.at(-1) + const prevTail = prev?.nodes.at(-1) + const closeInTime = prevTail ? Math.abs(node.startedAt - prevTail.startedAt) <= 5_000 : false + const sameShape = prev && node.taskCount > 1 && prev.taskCount === node.taskCount + const uniqueStep = prev ? !prev.nodes.some(item => item.taskIndex === node.taskIndex) : false + + if (prev && sameShape && closeInTime && uniqueStep) { + prev.nodes.push(node) + + continue + } + + if (node.taskCount > 1) { + n += 1 + groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount }) + + continue + } + + groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount }) + } + + return groups +} + +function SubagentTree({ tree }: { tree: SubagentNode[] }) { + const flat = useMemo(() => flatten(tree), [tree]) + const groups = useMemo(() => groupDelegations(tree), [tree]) + const [nowMs, setNowMs] = useState(() => Date.now()) + + const active = flat.filter(n => n.status === 'running' || n.status === 'queued').length + const failed = flat.filter(n => n.status === 'failed' || n.status === 'interrupted').length + const tools = flat.reduce((sum, n) => sum + (n.toolCount ?? 0), 0) + const files = flat.reduce((sum, n) => sum + n.filesRead.length + n.filesWritten.length, 0) + const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0) + const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0) + + useEffect(() => { + if (active <= 0 || typeof window === 'undefined') { + return + } + + const id = window.setInterval(() => setNowMs(Date.now()), 500) + + return () => window.clearInterval(id) + }, [active]) + + if (tree.length === 0) { + return ( + <div className="grid place-items-center gap-3 py-12 text-center"> + <Sparkles className="size-6 text-muted-foreground/60" /> + <p className="text-sm font-medium text-foreground/90">No live subagents</p> + <p className="max-w-md text-xs leading-relaxed text-muted-foreground/75"> + When a turn delegates work, child agents stream their progress here. + </p> + </div> + ) + } + + const summary = [ + `${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`, + active > 0 ? `${active} active` : '', + failed > 0 ? `${failed} failed` : '', + tools > 0 ? `${tools} tools` : '', + files > 0 ? `${files} files` : '', + tokens > 0 ? fmtTokens(tokens) : '', + cost > 0 ? `$${cost.toFixed(2)}` : '' + ].filter(Boolean) + + return ( + <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden"> + <p className="shrink-0 text-[0.7rem] text-muted-foreground/70">{summary.join(' · ')}</p> + <div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1"> + <div className="flex min-w-0 flex-col gap-6"> + {groups.map(group => ( + <DelegationGroup group={group} key={group.id} nowMs={nowMs} /> + ))} + </div> + </div> + </div> + ) +} + +function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) { + if (group.nodes.length === 1 && group.taskCount <= 1) { + return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} /> + } + + const activeWorkers = group.nodes.filter(n => n.status === 'running' || n.status === 'queued').length + + return ( + <section className="grid min-w-0 gap-3"> + <p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70"> + {group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers + {activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null} + </p> + <div className="grid min-w-0 gap-4"> + {group.nodes.map(node => ( + <SubagentRow key={node.id} node={node} nowMs={nowMs} /> + ))} + </div> + </section> + ) +} + +function StreamLine({ + active, + entry, + parentRunning, + rowKey +}: { + active: boolean + entry: SubagentStreamEntry + parentRunning: boolean + rowKey: string +}) { + const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`) + const isMono = entry.kind === 'tool' + const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] + + return ( + <div className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed" ref={enterRef}> + <span className="flex h-[0.95rem] shrink-0 items-center">{streamGlyph(entry)}</span> + <span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}> + {entry.text} + {active ? ( + <BrailleSpinner + ariaLabel="Streaming" + className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70" + spinner="breathe" + /> + ) : null} + </span> + </div> + ) +} + +function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) { + const running = node.status === 'running' || node.status === 'queued' + const elapsed = useElapsedSeconds(running, `subagent:${node.id}`) + + const durationSeconds = + typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed + + const [open, setOpen] = useState(() => running || depth < 2) + const enterRef = useEnterAnimation(true, `subagent-row:${node.id}`) + + useEffect(() => { + if (running) { + setOpen(true) + } + }, [running]) + + const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2) + const fileLines = [...node.filesWritten.map(p => `+ ${p}`), ...node.filesRead.map(p => `· ${p}`)] + + const subtitle = [ + node.model, + fmtDuration(durationSeconds), + node.toolCount ? `${node.toolCount} tools` : '', + fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)), + `updated ${fmtAge(node.updatedAt, nowMs)}` + ].filter(Boolean) + + return ( + <div className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')} data-slot="tool-block" ref={enterRef}> + <button + aria-expanded={open} + className="group flex w-full min-w-0 items-start gap-2.5 text-left" + onClick={() => setOpen(v => !v)} + type="button" + > + <span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span> + <span className="flex min-w-0 flex-1 flex-col gap-0.5"> + <span + className={cn( + 'wrap-anywhere text-[0.82rem] font-medium leading-[1.1rem] text-foreground/90 transition-colors group-hover:text-foreground', + running && 'shimmer text-foreground/65' + )} + > + {node.goal} + </span> + {subtitle.length > 0 ? ( + <FadeText className="text-[0.66rem] leading-[1.05rem] text-muted-foreground/65"> + {subtitle.join(' · ')} + </FadeText> + ) : null} + </span> + {running ? <ActivityTimerText className="mt-1 shrink-0 text-[0.6rem]" seconds={durationSeconds} /> : null} + </button> + + {visibleRows.length > 0 ? ( + <div className="grid min-w-0 gap-1 pl-6"> + {visibleRows.map((entry, i) => ( + <StreamLine + active={running && i === visibleRows.length - 1} + entry={entry} + key={`${entry.kind}:${entry.at}:${i}`} + parentRunning={running} + rowKey={`${node.id}:${entry.kind}:${entry.at}`} + /> + ))} + </div> + ) : null} + + {open && fileLines.length > 0 ? ( + <div className="grid min-w-0 gap-0.5 pl-6"> + <p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p> + {fileLines.slice(0, 8).map(line => ( + <p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}> + {line} + </p> + ))} + {fileLines.length > 8 ? ( + <p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65"> + +{fileLines.length - 8} more files + </p> + ) : null} + </div> + ) : null} + + {node.children.length > 0 ? ( + <div className="grid min-w-0 gap-3 pl-6"> + {node.children.map(child => ( + <SubagentRow depth={depth + 1} key={child.id} node={child} nowMs={nowMs} /> + ))} + </div> + ) : null} + </div> + ) +} diff --git a/apps/desktop/src/app/artifacts/index.test.ts b/apps/desktop/src/app/artifacts/index.test.ts new file mode 100644 index 000000000..ebca956a2 --- /dev/null +++ b/apps/desktop/src/app/artifacts/index.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo, SessionMessage } from '@/types/hermes' + +import { collectArtifactsForSession } from './index' + +function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo { + return { + ended_at: null, + id: 'session-1', + input_tokens: 0, + is_active: false, + last_active: 1000, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + source: null, + started_at: 1000, + title: 'Session', + tool_call_count: 0, + ...overrides + } +} + +describe('collectArtifactsForSession', () => { + it('indexes plain https links from assistant text', () => { + const artifacts = collectArtifactsForSession(makeSession(), [ + { + content: 'Reference: https://example.com/docs/getting-started', + role: 'assistant', + timestamp: 2000 + } + ]) + + expect(artifacts).toHaveLength(1) + expect(artifacts[0]).toMatchObject({ + href: 'https://example.com/docs/getting-started', + kind: 'link', + value: 'https://example.com/docs/getting-started' + }) + }) + + it('indexes http links present in tool JSON payloads', () => { + const messages: SessionMessage[] = [ + { + content: JSON.stringify({ source_url: 'https://example.com/changelog/latest' }), + role: 'tool', + timestamp: 3000 + } + ] + + const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages) + + expect(artifacts).toHaveLength(1) + expect(artifacts[0]).toMatchObject({ + href: 'https://example.com/changelog/latest', + kind: 'link', + value: 'https://example.com/changelog/latest' + }) + }) +}) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx new file mode 100644 index 000000000..f53366cb5 --- /dev/null +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -0,0 +1,883 @@ +import type * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { ZoomableImage } from '@/components/chat/zoomable-image' +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { CopyButton } from '@/components/ui/copy-button' +import { + Pagination, + PaginationButton, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationNext, + PaginationPrevious +} from '@/components/ui/pagination' +import { TextTab, TextTabMeta } from '@/components/ui/text-tab' +import { getSessionMessages, listSessions } from '@/hermes' +import { sessionTitle } from '@/lib/chat-runtime' +import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' +import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' +import type { SessionInfo, SessionMessage } from '@/types/hermes' + +import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { PageSearchShell } from '../page-search-shell' +import { sessionRoute } from '../routes' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' + +type ArtifactKind = 'image' | 'file' | 'link' +type ArtifactFilter = 'all' | ArtifactKind +const ARTIFACT_FILTERS: readonly ArtifactFilter[] = ['all', 'image', 'file', 'link'] + +interface ArtifactRecord { + id: string + kind: ArtifactKind + value: string + href: string + label: string + sessionId: string + sessionTitle: string + timestamp: number +} + +const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g +const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g +const URL_RE = /https?:\/\/[^\s<>"')]+/g +const PATH_RE = /(^|[\s("'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gi +const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i +const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i +const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i + +const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'short' +}) + +function normalizeValue(value: string): string { + return value.trim().replace(/[),.;]+$/, '') +} + +function parseMaybeJson(value: string): unknown { + if (!value.trim()) { + return null + } + + try { + return JSON.parse(value) + } catch { + return null + } +} + +function looksLikePathOrUrl(value: string): boolean { + return ( + value.startsWith('http://') || + value.startsWith('https://') || + value.startsWith('file://') || + value.startsWith('data:image/') || + value.startsWith('/') || + value.startsWith('./') || + value.startsWith('../') || + value.startsWith('~/') + ) +} + +function looksLikeArtifact(value: string): boolean { + if (/^(?:https?:\/\/|data:image\/)/.test(value)) { + return true + } + + if (looksLikePathOrUrl(value) && (IMAGE_EXT_RE.test(value) || FILE_EXT_RE.test(value))) { + return true + } + + return value.startsWith('/') && value.includes('.') +} + +function artifactKind(value: string): ArtifactKind { + if (value.startsWith('data:image/') || IMAGE_EXT_RE.test(value)) { + return 'image' + } + + if ( + value.startsWith('/') || + value.startsWith('./') || + value.startsWith('../') || + value.startsWith('~/') || + value.startsWith('file://') + ) { + return 'file' + } + + return 'link' +} + +function artifactHref(value: string): string { + if ( + value.startsWith('http://') || + value.startsWith('https://') || + value.startsWith('file://') || + value.startsWith('data:') + ) { + return value + } + + if (value.startsWith('/')) { + return `file://${encodeURI(value)}` + } + + return value +} + +function artifactLabel(value: string): string { + try { + const url = new URL(value) + const item = url.pathname.split('/').filter(Boolean).pop() + + return item || value + } catch { + const parts = value.split(/[\\/]/).filter(Boolean) + + return parts.pop() || value + } +} + +function messageText(message: SessionMessage): string { + if (typeof message.content === 'string' && message.content.trim()) { + return message.content + } + + if (typeof message.text === 'string' && message.text.trim()) { + return message.text + } + + if (typeof message.context === 'string' && message.context.trim()) { + return message.context + } + + return '' +} + +function collectStringValues( + value: unknown, + keyPath: string, + collector: (value: string, keyPath: string) => void +): void { + if (typeof value === 'string') { + collector(value, keyPath) + + return + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => collectStringValues(entry, `${keyPath}.${index}`, collector)) + + return + } + + if (!value || typeof value !== 'object') { + return + } + + for (const [key, child] of Object.entries(value as Record<string, unknown>)) { + collectStringValues(child, keyPath ? `${keyPath}.${key}` : key, collector) + } +} + +function collectArtifactsFromText(text: string, pushValue: (value: string) => void): void { + for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) { + pushValue(match[2] || '') + } + + for (const match of text.matchAll(MARKDOWN_LINK_RE)) { + const start = match.index ?? 0 + + if (start > 0 && text[start - 1] === '!') { + continue + } + + const value = match[2] || '' + + if (looksLikeArtifact(value)) { + pushValue(value) + } + } + + for (const match of text.matchAll(URL_RE)) { + const value = match[0] || '' + + if (looksLikeArtifact(value)) { + pushValue(value) + } + } + + for (const match of text.matchAll(PATH_RE)) { + pushValue(match[2] || '') + } +} + +function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value: string) => void): void { + const text = messageText(message) + + if (text) { + collectArtifactsFromText(text, pushValue) + } + + if (message.role !== 'tool' && !Array.isArray(message.tool_calls)) { + return + } + + if (Array.isArray(message.tool_calls)) { + for (const call of message.tool_calls) { + collectStringValues(call, 'tool_call', (value, keyPath) => { + const normalized = normalizeValue(value) + + if (!normalized) { + return + } + + if (KEY_HINT_RE.test(keyPath) && (looksLikePathOrUrl(normalized) || FILE_EXT_RE.test(normalized))) { + pushValue(normalized) + } + }) + } + } + + const parsed = parseMaybeJson(text) + + if (parsed !== null) { + collectStringValues(parsed, 'tool_result', (value, keyPath) => { + const normalized = normalizeValue(value) + + if (!normalized) { + return + } + + if ((KEY_HINT_RE.test(keyPath) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) { + pushValue(normalized) + } + }) + } +} + +export function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] { + const found = new Map<string, ArtifactRecord>() + const title = sessionTitle(session) + + for (const message of messages) { + if (message.role !== 'assistant' && message.role !== 'tool') { + continue + } + + collectArtifactsFromMessage(message, candidate => { + const value = normalizeValue(candidate) + + if (!value || !looksLikeArtifact(value)) { + return + } + + const key = `${session.id}:${value}` + + if (found.has(key)) { + return + } + + found.set(key, { + id: key, + kind: artifactKind(value), + value, + href: artifactHref(value), + label: artifactLabel(value), + sessionId: session.id, + sessionTitle: title, + timestamp: message.timestamp || session.last_active || session.started_at || Date.now() + }) + }) + } + + return Array.from(found.values()) +} + +function formatArtifactTime(timestamp: number): string { + return ARTIFACT_TIME_FMT.format(new Date(timestamp)) +} + +function pageRangeLabel(total: number, page: number, pageSize: number): string { + if (total === 0) { + return '0' + } + + const start = (page - 1) * pageSize + 1 + const end = Math.min(total, page * pageSize) + + return `${start}-${end} of ${total}` +} + +function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> { + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, index) => index + 1) + } + + const pages: Array<number | 'ellipsis'> = [1] + const start = Math.max(2, page - 1) + const end = Math.min(pageCount - 1, page + 1) + + if (start > 2) { + pages.push('ellipsis') + } + + for (let nextPage = start; nextPage <= end; nextPage += 1) { + pages.push(nextPage) + } + + if (end < pageCount - 1) { + pages.push('ellipsis') + } + + pages.push(pageCount) + + return pages +} + +type CellCtx = { + onOpen: (href: string) => void | Promise<void> + onOpenChat: (sessionId: string) => void +} + +interface ArtifactColumn { + Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement + bodyClassName: string + header: (filter: ArtifactFilter) => string + id: 'location' | 'primary' | 'session' + width: (filter: ArtifactFilter) => string +} + +const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items') + +interface ArtifactsViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup +} + +export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) { + const navigate = useNavigate() + const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null) + const [query, setQuery] = useState('') + + const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all') + + const [refreshing, setRefreshing] = useState(false) + const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set()) + const [imagePage, setImagePage] = useState(1) + const [filePage, setFilePage] = useState(1) + + const refreshArtifacts = useCallback(async () => { + setRefreshing(true) + + try { + const sessions = (await listSessions(30, 1)).sessions + const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) + const nextArtifacts: ArtifactRecord[] = [] + + results.forEach((result, index) => { + if (result.status !== 'fulfilled') { + return + } + + const session = sessions[index] + nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages)) + }) + + setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp)) + } catch (err) { + notifyError(err, 'Artifacts failed to load') + setArtifacts([]) + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + void refreshArtifacts() + }, [refreshArtifacts]) + + useEffect(() => { + setImagePage(1) + setFilePage(1) + }, [artifacts, kindFilter, query]) + + const visibleArtifacts = useMemo(() => { + if (!artifacts) { + return [] + } + + const q = query.trim().toLowerCase() + + return artifacts.filter(artifact => { + if (kindFilter !== 'all' && artifact.kind !== kindFilter) { + return false + } + + if (!q) { + return true + } + + return ( + artifact.label.toLowerCase().includes(q) || + artifact.value.toLowerCase().includes(q) || + artifact.sessionTitle.toLowerCase().includes(q) + ) + }) + }, [artifacts, kindFilter, query]) + + const visibleImageArtifacts = useMemo( + () => visibleArtifacts.filter(artifact => artifact.kind === 'image'), + [visibleArtifacts] + ) + + const visibleFileArtifacts = useMemo( + () => visibleArtifacts.filter(artifact => artifact.kind !== 'image'), + [visibleArtifacts] + ) + + const imagePageCount = Math.max(1, Math.ceil(visibleImageArtifacts.length / 24)) + const filePageCount = Math.max(1, Math.ceil(visibleFileArtifacts.length / 100)) + const currentImagePage = Math.min(imagePage, imagePageCount) + const currentFilePage = Math.min(filePage, filePageCount) + + const pagedImageArtifacts = useMemo( + () => visibleImageArtifacts.slice((currentImagePage - 1) * 24, currentImagePage * 24), + [currentImagePage, visibleImageArtifacts] + ) + + const pagedFileArtifacts = useMemo( + () => visibleFileArtifacts.slice((currentFilePage - 1) * 100, currentFilePage * 100), + [currentFilePage, visibleFileArtifacts] + ) + + const counts = useMemo(() => { + const all = artifacts || [] + + return { + all: all.length, + image: all.filter(artifact => artifact.kind === 'image').length, + file: all.filter(artifact => artifact.kind === 'file').length, + link: all.filter(artifact => artifact.kind === 'link').length + } + }, [artifacts]) + + const openArtifact = useCallback(async (href: string) => { + try { + if (window.hermesDesktop?.openExternal) { + await window.hermesDesktop.openExternal(href) + } else { + window.open(href, '_blank', 'noopener,noreferrer') + } + } catch (err) { + notifyError(err, 'Open failed') + } + }, []) + + const markImageFailed = useCallback((id: string) => { + setFailedImageIds(current => { + if (current.has(id)) { + return current + } + + return new Set(current).add(id) + }) + }, []) + + const cellCtx: CellCtx = { + onOpen: openArtifact, + onOpenChat: sessionId => navigate(sessionRoute(sessionId)) + } + + return ( + <PageSearchShell + {...props} + filters={ + <> + <TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}> + All <TextTabMeta>({counts.all})</TextTabMeta> + </TextTab> + <TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}> + Images <TextTabMeta>({counts.image})</TextTabMeta> + </TextTab> + <TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}> + Files <TextTabMeta>({counts.file})</TextTabMeta> + </TextTab> + <TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}> + Links <TextTabMeta>({counts.link})</TextTabMeta> + </TextTab> + </> + } + onSearchChange={setQuery} + searchPlaceholder="Search artifacts..." + searchTrailingAction={ + <Button + aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'} + className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground" + disabled={refreshing} + onClick={() => void refreshArtifacts()} + size="icon-xs" + title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'} + type="button" + variant="ghost" + > + <Codicon name="refresh" size="0.875rem" spinning={refreshing} /> + </Button> + } + searchValue={query} + > + {!artifacts ? ( + <PageLoader label="Indexing recent session artifacts" /> + ) : visibleArtifacts.length === 0 ? ( + <div className="grid h-full place-items-center px-6 text-center"> + <div> + <div className="text-sm font-medium">No artifacts found</div> + <div className="mt-1 text-xs text-muted-foreground"> + Generated images and file outputs will appear here as sessions produce them. + </div> + </div> + </div> + ) : ( + <div className="h-full overflow-y-auto"> + <div className="flex flex-col gap-3 px-2 pb-2"> + {visibleImageArtifacts.length > 0 && ( + <section className="flex flex-col"> + <div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3"> + <ArtifactsPagination + className="ml-auto justify-end px-0" + itemLabel="images" + onPageChange={setImagePage} + page={currentImagePage} + pageSize={24} + total={visibleImageArtifacts.length} + /> + </div> + <div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] items-start gap-2 pt-1.5"> + {pagedImageArtifacts.map(artifact => ( + <ArtifactImageCard + artifact={artifact} + failedImage={failedImageIds.has(artifact.id)} + key={artifact.id} + onImageError={markImageFailed} + onOpenChat={sessionId => navigate(sessionRoute(sessionId))} + /> + ))} + </div> + </section> + )} + + {visibleFileArtifacts.length > 0 && ( + <section className="flex flex-col"> + <div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3"> + <ArtifactsPagination + className="ml-auto justify-end px-0" + itemLabel={itemsLabel(kindFilter)} + onPageChange={setFilePage} + page={currentFilePage} + pageSize={100} + total={visibleFileArtifacts.length} + /> + </div> + <div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm"> + <ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} /> + </div> + </section> + )} + </div> + </div> + )} + </PageSearchShell> + ) +} + +interface ArtifactsPaginationProps { + className?: string + itemLabel: string + onPageChange: (page: number) => void + page: number + pageSize: number + total: number +} + +function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) { + const pageCount = Math.max(1, Math.ceil(total / pageSize)) + + return ( + <div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}> + <div className="shrink-0 text-[0.62rem] text-muted-foreground"> + {pageRangeLabel(total, page, pageSize)} {itemLabel} + </div> + {pageCount > 1 && ( + <Pagination className="mx-0 w-auto min-w-0 justify-end"> + <PaginationContent className="gap-0.5"> + <PaginationItem> + <PaginationPrevious disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))} /> + </PaginationItem> + {paginationItems(page, pageCount).map((item, index) => ( + <PaginationItem key={`${item}-${index}`}> + {item === 'ellipsis' ? ( + <PaginationEllipsis /> + ) : ( + <PaginationButton + aria-label={`Go to ${itemLabel} page ${item}`} + isActive={page === item} + onClick={() => onPageChange(item)} + > + {item} + </PaginationButton> + )} + </PaginationItem> + ))} + <PaginationItem> + <PaginationNext + disabled={page >= pageCount} + onClick={() => onPageChange(Math.min(pageCount, page + 1))} + /> + </PaginationItem> + </PaginationContent> + </Pagination> + )} + </div> + ) +} + +interface ArtifactImageCardProps { + artifact: ArtifactRecord + failedImage: boolean + onImageError: (id: string) => void + onOpenChat: (sessionId: string) => void +} + +function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) { + return ( + <article + className={cn( + 'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm' + )} + > + <div + className={cn( + 'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5', + failedImage && 'cursor-default' + )} + > + {!failedImage && ( + <ZoomableImage + alt={artifact.label} + className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm" + containerClassName="max-h-full" + decoding="async" + loading="lazy" + onError={() => onImageError(artifact.id)} + slot="artifact-media" + src={artifact.href} + /> + )} + </div> + + <div className="space-y-1.5 p-2"> + <div className="min-w-0"> + <div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)"> + <FileImage className="size-3" /> + {artifact.kind} + </div> + <div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium"> + {artifact.label} + </div> + <div className="mt-0.5 truncate text-[0.625rem] text-(--ui-text-tertiary)">{artifact.value}</div> + </div> + + <div className="truncate text-[0.625rem] text-(--ui-text-tertiary)"> + {artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)} + </div> + + <div className="flex flex-wrap gap-1.5"> + <Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline"> + <FolderOpen className="size-3" /> + Chat + </Button> + </div> + </div> + </article> + ) +} + +// Single click target for any row cell. External URLs render as <ExternalLink>; +// local actions render as <button>. Padding lives here, NOT on the <td>, so +// the entire cell area is hoverable and clickable in both branches. +function ArtifactCellAction({ + children, + href, + onClick, + title +}: { + children: React.ReactNode + href?: string + onClick?: () => void + title?: string +}) { + if (href) { + return ( + <ExternalLink + className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline" + href={href} + showExternalIcon={false} + title={title} + > + {children} + </ExternalLink> + ) + } + + return ( + <button + className={cn( + 'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline', + 'cursor-pointer' + )} + onClick={onClick} + title={title} + type="button" + > + {children} + </button> + ) +} + +function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) { + const isLink = artifact.kind === 'link' + const Icon = isLink ? Link2 : FileText + const fetchedTitle = useLinkTitle(isLink ? artifact.href : null) + const label = isLink ? fetchedTitle || urlSlugTitleLabel(artifact.href) : artifact.label + + return ( + <ArtifactCellAction + href={isLink ? artifact.href : undefined} + onClick={isLink ? undefined : () => void ctx.onOpen(artifact.href)} + title={label} + > + <span className="mt-0.5 grid size-6 shrink-0 place-items-center self-start rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)"> + <Icon className="size-3.5" /> + </span> + <span className={cn('min-w-0 flex-1', isLink ? 'wrap-anywhere' : 'truncate')}> + {label} + {isLink && <ExternalLinkIcon />} + </span> + </ArtifactCellAction> + ) +} + +function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) { + const isLink = artifact.kind === 'link' + const value = isLink ? hostPathLabel(artifact.value) : artifact.value + const copyLabel = isLink ? 'Copy URL' : 'Copy path' + + return ( + <div className="group/location flex min-w-0 items-center gap-1.5"> + <div + className={cn( + 'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)', + isLink ? 'font-normal' : 'font-mono' + )} + title={artifact.value} + > + {value} + </div> + <CopyButton + appearance="icon" + buttonSize="icon-xs" + className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/location:opacity-100" + iconClassName="size-3.5" + label={copyLabel} + text={artifact.value} + title={copyLabel} + /> + </div> + ) +} + +function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) { + return ( + <ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}> + <span className="flex min-w-0 flex-col"> + <span className="truncate">{artifact.sessionTitle}</span> + <span className="truncate text-[0.6875rem] font-normal text-(--ui-text-tertiary)"> + {formatArtifactTime(artifact.timestamp)} + </span> + </span> + </ArtifactCellAction> + ) +} + +const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [ + { + Cell: PrimaryCell, + bodyClassName: 'p-0', + header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'), + id: 'primary', + width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]') + }, + { + Cell: LocationCell, + bodyClassName: 'px-2.5 py-1.5', + header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'), + id: 'location', + width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]') + }, + { + Cell: SessionCell, + bodyClassName: 'p-0', + header: () => 'Session', + id: 'session', + width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]') + } +] + +function ArtifactTable({ + artifacts, + ctx, + filter +}: { + artifacts: readonly ArtifactRecord[] + ctx: CellCtx + filter: ArtifactFilter +}) { + return ( + <table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]"> + <thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)"> + <tr> + {ARTIFACT_COLUMNS.map(col => ( + <th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}> + {col.header(filter)} + </th> + ))} + </tr> + </thead> + <tbody className="divide-y divide-(--ui-stroke-quaternary)"> + {artifacts.map(artifact => ( + <tr className="group/artifact" key={artifact.id}> + {ARTIFACT_COLUMNS.map(col => { + const Cell = col.Cell + + return ( + <td className={cn('align-middle', col.bodyClassName)} key={col.id}> + <Cell artifact={artifact} ctx={ctx} /> + </td> + ) + })} + </tr> + ))} + </tbody> + </table> + ) +} diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx new file mode 100644 index 000000000..8d515fa95 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -0,0 +1,110 @@ +import { useStore } from '@nanostores/react' + +import { Codicon } from '@/components/ui/codicon' +import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons' +import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import type { ComposerAttachment } from '@/store/composer' +import { notifyError } from '@/store/notifications' +import { setCurrentSessionPreviewTarget } from '@/store/preview' +import { $currentCwd } from '@/store/session' + +export function AttachmentList({ + attachments, + onRemove +}: { + attachments: ComposerAttachment[] + onRemove?: (id: string) => void +}) { + return ( + <div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments"> + {attachments.map(attachment => ( + <AttachmentPill attachment={attachment} key={attachment.id} onRemove={onRemove} /> + ))} + </div> + ) +} + +function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) { + const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] + const cwd = useStore($currentCwd) + const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' + const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined + + async function openPreview() { + if (!canPreview) { + return + } + + const rawTarget = + attachment.path || + attachment.detail || + attachment.refText?.replace(/^@(file|image|url):/, '') || + attachment.label || + '' + + const target = rawTarget.replace(/^`|`$/g, '') + + if (!target) { + return + } + + try { + const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined) + + if (!preview) { + throw new Error(`Could not preview ${attachment.label}`) + } + + setCurrentSessionPreviewTarget(preview, 'manual', target) + } catch (error) { + notifyError(error, 'Preview unavailable') + } + } + + return ( + <div + className="group/attachment relative min-w-0 shrink-0" + title={attachment.path || attachment.detail || attachment.label} + > + <button + aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label} + className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default" + disabled={!canPreview} + onClick={() => void openPreview()} + title={canPreview ? `Preview ${attachment.label}` : attachment.label} + type="button" + > + {attachment.previewUrl && attachment.kind === 'image' ? ( + <img + alt={attachment.label} + className="size-8 shrink-0 border border-border/70 object-cover" + draggable={false} + src={attachment.previewUrl} + /> + ) : ( + <span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground"> + <Icon className="size-3.5" /> + </span> + )} + <span className="min-w-0"> + <span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90"> + {attachment.label} + </span> + {detail && ( + <span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span> + )} + </span> + </button> + {onRemove && ( + <button + aria-label={`Remove ${attachment.label}`} + className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100" + onClick={() => onRemove(attachment.id)} + type="button" + > + <Codicon name="close" size="0.625rem" /> + </button> + )} + </div> + ) +} diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx new file mode 100644 index 000000000..8b23c54f8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -0,0 +1,63 @@ +import type { Unstable_TriggerAdapter } from '@assistant-ui/core' +import { ComposerPrimitive } from '@assistant-ui/react' +import type { ReactNode } from 'react' + +export const COMPLETION_DRAWER_CLASS = [ + 'absolute bottom-[calc(100%+0.25rem)] left-0 z-50', + 'w-60 max-w-[calc(100vw-2rem)]', + 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', + 'rounded-lg border border-(--ui-stroke-secondary)', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]', + 'p-1 text-xs text-popover-foreground shadow-md', + 'backdrop-blur-md' +].join(' ') + +export const COMPLETION_DRAWER_BELOW_CLASS = [ + 'absolute left-0 top-[calc(100%+0.25rem)] z-50', + 'w-60 max-w-[calc(100vw-2rem)]', + 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', + 'rounded-lg border border-(--ui-stroke-secondary)', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]', + 'p-1 text-xs text-popover-foreground shadow-md', + 'backdrop-blur-md' +].join(' ') + +export const COMPLETION_DRAWER_ROW_CLASS = [ + 'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1', + 'w-full min-w-0 text-left text-xs outline-hidden transition-colors', + 'hover:bg-(--ui-bg-tertiary)', + 'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground' +].join(' ') + +export function ComposerCompletionDrawer({ + adapter, + ariaLabel, + char, + children +}: { + adapter: Unstable_TriggerAdapter + ariaLabel: string + char: string + children: ReactNode +}) { + return ( + <ComposerPrimitive.Unstable_TriggerPopover + adapter={adapter} + aria-label={ariaLabel} + char={char} + className={COMPLETION_DRAWER_CLASS} + data-slot="composer-completion-drawer" + > + {children} + </ComposerPrimitive.Unstable_TriggerPopover> + ) +} + +export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) { + return ( + <div className="px-3 py-3 text-xs text-(--ui-text-tertiary)"> + <p>{title}</p> + {children && <p className="mt-1 text-xs text-(--ui-text-tertiary)">{children}</p>} + </div> + ) +} diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx new file mode 100644 index 000000000..74de7b3b7 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -0,0 +1,123 @@ +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { GHOST_ICON_BTN } from './controls' +import type { ChatBarState } from './types' + +export function ContextMenu({ + state, + onInsertText, + onOpenUrlDialog, + onPasteClipboardImage, + onPickFiles, + onPickFolders, + onPickImages +}: { + state: ChatBarState + onInsertText: (text: string) => void + onOpenUrlDialog: () => void + onPasteClipboardImage?: () => void + onPickFiles?: () => void + onPickFolders?: () => void + onPickImages?: () => void +}) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label={state.tools.label} + className={cn( + GHOST_ICON_BTN, + 'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground' + )} + disabled={!state.tools.enabled} + size="icon" + title={state.tools.label} + type="button" + variant="ghost" + > + <Codicon name="add" size="1rem" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}> + <DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85"> + Attach + </DropdownMenuLabel> + <ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}> + Files… + </ContextMenuItem> + <ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}> + Folder… + </ContextMenuItem> + <ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}> + Images… + </ContextMenuItem> + <ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}> + Paste image + </ContextMenuItem> + <ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}> + URL… + </ContextMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuSub> + <DropdownMenuSubTrigger> + <MessageSquareText /> + <span>Prompt snippets</span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className="w-72"> + {[ + { label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' }, + { label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' }, + { label: 'Explain this', text: 'Please explain how this works and point me to the key files.' } + ].map(snippet => ( + <ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}> + {snippet.label} + </ContextMenuItem> + ))} + </DropdownMenuSubContent> + </DropdownMenuSub> + + <DropdownMenuSeparator /> + + <div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80"> + Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files + inline. + </div> + </DropdownMenuContent> + </DropdownMenu> + ) +} + +export function ContextMenuItem({ + children, + disabled, + icon: Icon, + onSelect +}: { + children: string + disabled?: boolean + icon: IconComponent + onSelect?: () => void +}) { + return ( + <DropdownMenuItem disabled={disabled} onSelect={onSelect}> + <Icon /> + <span>{children}</span> + </DropdownMenuItem> + ) +} diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx new file mode 100644 index 000000000..bd4b140b4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -0,0 +1,257 @@ +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { triggerHaptic } from '@/lib/haptics' +import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import type { ConversationStatus } from './hooks/use-voice-conversation' +import type { ChatBarState, VoiceStatus } from './types' + +export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' +export const GHOST_ICON_BTN = cn( + ICON_BTN, + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' +) +// Send/voice-conversation primary: solid foreground-on-background circle +// (reads as black-on-white in light mode, white-on-black in dark mode) to +// match the reference composer's high-contrast CTA. Keeps the pill itself +// neutral and lets the action visually dominate the row. +export const PRIMARY_ICON_BTN = cn( + 'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0', + 'bg-foreground text-background hover:bg-foreground/90', + 'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100' +) + +interface ConversationProps { + active: boolean + level: number + muted: boolean + status: ConversationStatus + onEnd: () => void + onStart: () => void + onStopTurn: () => void + onToggleMute: () => void +} + +export function ComposerControls({ + busy, + busyAction, + canSubmit, + conversation, + disabled, + hasComposerPayload, + state, + voiceStatus, + onDictate +}: { + busy: boolean + busyAction: 'queue' | 'stop' + canSubmit: boolean + conversation: ConversationProps + disabled: boolean + hasComposerPayload: boolean + state: ChatBarState + voiceStatus: VoiceStatus + onDictate: () => void +}) { + if (conversation.active) { + return <ConversationPill {...conversation} disabled={disabled} /> + } + + const showVoicePrimary = !busy && !hasComposerPayload + + return ( + <div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)"> + <DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} /> + {showVoicePrimary ? ( + <Button + aria-label="Start voice conversation" + className={PRIMARY_ICON_BTN} + disabled={disabled} + onClick={() => { + triggerHaptic('open') + conversation.onStart() + }} + size="icon" + title="Start voice conversation" + type="button" + > + <AudioLines size={17} /> + </Button> + ) : ( + <Button + aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'} + className={PRIMARY_ICON_BTN} + disabled={disabled || !canSubmit} + title={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'} + type="submit" + > + {busy ? ( + busyAction === 'queue' ? ( + <Layers3 size={16} /> + ) : ( + <span className="block size-3 rounded-[0.1875rem] bg-current" /> + ) + ) : ( + <Codicon name="arrow-up" size="1rem" /> + )} + </Button> + )} + </div> + ) +} + +function ConversationPill({ + disabled, + level, + muted, + onEnd, + onStopTurn, + onToggleMute, + status +}: ConversationProps & { disabled: boolean }) { + const speaking = status === 'speaking' + const listening = status === 'listening' && !muted + + const label = + status === 'speaking' + ? 'Speaking' + : status === 'transcribing' + ? 'Transcribing' + : status === 'thinking' + ? 'Thinking' + : muted + ? 'Muted' + : 'Listening' + + return ( + <div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)"> + <Button + aria-label={muted ? 'Unmute microphone' : 'Mute microphone'} + aria-pressed={muted} + className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')} + disabled={disabled} + onClick={() => { + triggerHaptic('selection') + onToggleMute() + }} + size="icon" + title={muted ? 'Unmute microphone' : 'Mute microphone'} + type="button" + variant="ghost" + > + <Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" /> + </Button> + {listening && ( + <Button + aria-label="Stop listening and send" + className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" + disabled={disabled} + onClick={() => { + triggerHaptic('submit') + onStopTurn() + }} + title="Stop listening and send" + type="button" + variant="ghost" + > + <Square className="fill-current" size={11} /> + <span>Stop</span> + </Button> + )} + <Button + aria-label="End voice conversation" + className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90" + disabled={disabled} + onClick={() => { + triggerHaptic('close') + onEnd() + }} + title="End voice conversation" + type="button" + > + <ConversationIndicator level={level} listening={listening} speaking={speaking} /> + <span>End</span> + </Button> + <span className="sr-only" role="status"> + {label} + </span> + </div> + ) +} + +function ConversationIndicator({ + level, + listening, + speaking +}: { + level: number + listening: boolean + speaking: boolean +}) { + if (speaking) { + return <Loader2 className="animate-spin" size={12} /> + } + + const bars = [0.55, 0.85, 1, 0.85, 0.55] + const normalized = Math.max(0, Math.min(level, 1)) + + return ( + <span aria-hidden="true" className="flex h-3 items-center gap-0.5"> + {bars.map((weight, index) => { + const height = listening ? 0.3 + Math.min(0.7, normalized * weight) : 0.3 + + return <span className="w-0.5 rounded-full bg-current" key={index} style={{ height: `${height * 100}%` }} /> + })} + </span> + ) +} + +function DictationButton({ + disabled, + state, + status, + onToggle +}: { + disabled: boolean + state: ChatBarState['voice'] + status: VoiceStatus + onToggle: () => void +}) { + const active = state.active || status !== 'idle' + + const aria = + status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation' + + return ( + <Button + aria-label={aria} + aria-pressed={active} + className={cn( + GHOST_ICON_BTN, + 'p-0', + 'data-[active=true]:bg-accent data-[active=true]:text-foreground', + status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary', + status === 'transcribing' && 'bg-primary/10 text-primary' + )} + data-active={active} + disabled={disabled || !state.enabled || status === 'transcribing'} + onClick={() => { + triggerHaptic(active ? 'close' : 'open') + onToggle() + }} + size="icon" + title={aria} + type="button" + variant="ghost" + > + {status === 'recording' ? ( + <Square className="fill-current" size={12} /> + ) : status === 'transcribing' ? ( + <Loader2 className="animate-spin" size={16} /> + ) : ( + <Codicon name="mic" size="1rem" /> + )} + </Button> + ) +} diff --git a/apps/desktop/src/app/chat/composer/drop-affordance.ts b/apps/desktop/src/app/chat/composer/drop-affordance.ts new file mode 100644 index 000000000..3426ec282 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/drop-affordance.ts @@ -0,0 +1,2 @@ +export const COMPOSER_DROP_FADE_CLASS = 'transition-opacity duration-150 ease-out' +export const COMPOSER_DROP_ACTIVE_CLASS = 'opacity-60' diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts new file mode 100644 index 000000000..bf9e72b4b --- /dev/null +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -0,0 +1,103 @@ +/** + * Composer focus + external-insert bus. + * + * Mutations from outside the composer (sidebar attach, drag drop, terminal + * Cmd+L, preview console, etc.) dispatch through here. Each composer subscribes + * and routes the work back into its own ref/state. + * + * `dispatch` defers to a macrotask so synchronous click/keydown handlers + * (react-arborist row focus, picker `node.select()`) finish first and don't + * steal focus from the composer effect. + */ + +export type ComposerTarget = 'edit' | 'main' +export type ComposerInsertMode = 'block' | 'inline' + +interface FocusDetail { + target: ComposerTarget +} + +interface InsertDetail { + mode: ComposerInsertMode + target: ComposerTarget + text: string +} + +const FOCUS_EVENT = 'hermes:composer-focus' +const INSERT_EVENT = 'hermes:composer-insert' + +let activeTarget: ComposerTarget = 'main' + +const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target) + +const dispatch = <T>(name: string, detail: T) => { + if (typeof window === 'undefined') { + return + } + + window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0) +} + +const subscribe = <T>(name: string, handler: (detail: T) => void) => { + if (typeof window === 'undefined') { + return () => undefined + } + + const listener = (event: Event) => { + const detail = (event as CustomEvent<T>).detail + + if (detail) { + handler(detail) + } + } + + window.addEventListener(name, listener) + + return () => window.removeEventListener(name, listener) +} + +export const markActiveComposer = (target: ComposerTarget) => { + activeTarget = target +} + +export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') => + dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) }) + +export const requestComposerInsert = ( + text: string, + { mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {} +) => { + const trimmed = text.trim() + + if (!trimmed) { + return + } + + dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed }) +} + +export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) => + subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target)) + +export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) => + subscribe<InsertDetail>(INSERT_EVENT, handler) + +/** + * Focus a composer input across React commit + browser focus restore. + * + * The triple-call survives: + * - sync: contenteditable already mounted + * - rAF: React just committed a `renderComposerContents` swap + * - 0ms: browser focus reclaim from a click target inside an external panel + */ +export const focusComposerInput = (el: HTMLElement | null) => { + if (!el) { + return + } + + const focus = () => el.focus({ preventScroll: true }) + + focus() + window.requestAnimationFrame(focus) + window.setTimeout(focus, 0) +} diff --git a/apps/desktop/src/app/chat/composer/help-hint.tsx b/apps/desktop/src/app/chat/composer/help-hint.tsx new file mode 100644 index 000000000..c986f20f4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/help-hint.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react' + +import { COMPLETION_DRAWER_CLASS } from './completion-drawer' + +const COMMON_COMMANDS: [string, string][] = [ + ['/help', 'full list of commands + hotkeys'], + ['/clear', 'start a new session'], + ['/resume', 'resume a prior session'], + ['/details', 'control transcript detail level'], + ['/copy', 'copy selection or last assistant message'], + ['/quit', 'exit hermes'] +] + +const HOTKEYS: [string, string][] = [ + ['@', 'reference files, folders, urls, git'], + ['/', 'slash command palette'], + ['?', 'this quick help (delete to dismiss)'], + ['Enter', 'send · Shift+Enter for newline'], + ['Cmd/Ctrl+K', 'send next queued turn'], + ['Cmd/Ctrl+L', 'redraw'], + ['Esc', 'close popover · cancel run'], + ['↑ / ↓', 'cycle popover / history'] +] + +export function HelpHint() { + return ( + <div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog"> + <Section title="Common commands"> + {COMMON_COMMANDS.map(([key, desc]) => ( + <Row description={desc} key={key} keyLabel={key} mono /> + ))} + </Section> + + <Section title="Hotkeys"> + {HOTKEYS.map(([key, desc]) => ( + <Row description={desc} key={key} keyLabel={key} /> + ))} + </Section> + + <p className="px-2.5 py-1 text-xs text-muted-foreground/80"> + <span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses + </p> + </div> + ) +} + +function Section({ children, title }: { children: ReactNode; title: string }) { + return ( + <div className="grid gap-0.5 pt-0.5"> + <p className="px-2.5 pb-0.5 pt-1 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground/75"> + {title} + </p> + {children} + </div> + ) +} + +function Row({ description, keyLabel, mono = false }: { description: string; keyLabel: string; mono?: boolean }) { + return ( + <div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs"> + <span + className={ + mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85' + } + > + {keyLabel} + </span> + <span className="min-w-0 truncate text-muted-foreground/80">{description}</span> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts new file mode 100644 index 000000000..4d6a68d90 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-at-completions.ts @@ -0,0 +1,141 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useCallback } from 'react' + +import type { HermesGateway } from '@/hermes' + +import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter' +import { useLiveCompletionAdapter } from './use-live-completion-adapter' + +const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/ +const REF_STARTERS = new Set(['file', 'folder', 'url', 'image', 'tool', 'git']) + +const STARTER_META: Record<string, string> = { + file: 'Attach a file reference', + folder: 'Attach a folder reference', + url: 'Attach a URL reference', + image: 'Attach an image reference', + tool: 'Attach a tool reference', + git: 'Attach git context' +} + +function starterEntries(query: string): CompletionEntry[] { + const q = query.trim().toLowerCase() + const kinds = Array.from(REF_STARTERS) + const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds + + return filtered.map(kind => ({ + text: `@${kind}:`, + display: `@${kind}:`, + meta: STARTER_META[kind] || '' + })) +} + +interface AtItemMetadata extends Record<string, string> { + icon: string + display: string + meta: string + /** Raw `text` field from the gateway, e.g. `@file:src/main.tsx` or `@diff`. */ + rawText: string + /** Just the value portion (after `@kind:`), or empty for simple refs. */ + insertId: string +} + +function textValue(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +/** Parse the gateway's `text` field (`@file:src/foo.ts`, `@diff`, `@folder:`) into popover-ready data. */ +function classify(entry: CompletionEntry): { + type: string + insertId: string + display: string + meta: string +} { + const match = KIND_RE.exec(entry.text) + + if (match) { + const [, kind, rest] = match + + return { + type: kind, + insertId: rest, + display: textValue(entry.display, rest || `@${kind}:`), + meta: textValue(entry.meta) + } + } + + return { + type: 'simple', + insertId: entry.text, + display: textValue(entry.display, entry.text), + meta: textValue(entry.meta) + } +} + +/** Live `@` completions backed by the gateway's `complete.path` RPC. */ +export function useAtCompletions(options: { + gateway: HermesGateway | null + sessionId: string | null + cwd: string | null +}): { adapter: Unstable_TriggerAdapter; loading: boolean } { + const { gateway, sessionId, cwd } = options + const enabled = Boolean(gateway) + + const fetcher = useCallback( + async (query: string): Promise<CompletionPayload> => { + const starters = starterEntries(query) + + if (!gateway) { + return { items: starters, query } + } + + const word = REF_STARTERS.has(query) ? `@${query}:` : `@${query}` + const params: Record<string, unknown> = { word } + + if (sessionId) { + params.session_id = sessionId + } + + if (cwd) { + params.cwd = cwd + } + + try { + const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params) + const items = result.items ?? [] + + return { items: items.length > 0 ? items : starters, query } + } catch { + return { items: starters, query } + } + }, + [gateway, sessionId, cwd] + ) + + const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => { + const classified = classify(entry) + + const metadata: AtItemMetadata = { + icon: classified.type, + display: classified.display, + meta: classified.meta, + rawText: entry.text, + insertId: classified.insertId + } + + return { + // Unique id keyed on the gateway's full `text` so two entries that share + // a basename (e.g. multiple `index.ts`) don't collide in keyboard nav. + id: `${entry.text}|${index}`, + type: classified.type, + label: classified.display, + ...(classified.meta ? { description: classified.meta } : {}), + metadata + } + }, []) + + return useLiveCompletionAdapter({ enabled, fetcher, toItem }) +} + +/** Re-export `classify` for use by the formatter (insertion side). */ +export { classify } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts new file mode 100644 index 000000000..fbeca7d59 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -0,0 +1,119 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +export interface CompletionEntry { + text: string + display?: unknown + meta?: unknown +} + +export interface CompletionPayload { + items: CompletionEntry[] + query: string +} + +const EMPTY_QUERY = '\u0000' + +export function useLiveCompletionAdapter(options: { + enabled: boolean + debounceMs?: number + fetcher: (query: string) => Promise<CompletionPayload> + toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem +}): { adapter: Unstable_TriggerAdapter; loading: boolean } { + const { enabled, debounceMs = 60, fetcher, toItem } = options + + const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({ + query: EMPTY_QUERY, + items: [] + }) + + const [loading, setLoading] = useState(false) + + const tokenRef = useRef(0) + const timerRef = useRef<number | null>(null) + const pendingQueryRef = useRef<string | null>(null) + + const cancelTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + useEffect(() => () => cancelTimer(), [cancelTimer]) + + useEffect(() => { + if (enabled) { + return + } + + cancelTimer() + pendingQueryRef.current = null + tokenRef.current += 1 + setLoading(false) + setState({ query: EMPTY_QUERY, items: [] }) + }, [cancelTimer, enabled]) + + const scheduleFetch = useCallback( + (query: string) => { + if (!enabled) { + return + } + + if (pendingQueryRef.current === query) { + return + } + + pendingQueryRef.current = query + cancelTimer() + const token = ++tokenRef.current + setLoading(true) + + timerRef.current = window.setTimeout(() => { + timerRef.current = null + + fetcher(query) + .then(payload => { + if (token !== tokenRef.current) { + return + } + + setState({ + query: payload.query, + items: payload.items.map((entry, index) => toItem(entry, index)) + }) + }) + .catch(() => { + if (token !== tokenRef.current) { + return + } + + setState({ query, items: [] }) + }) + .finally(() => { + if (token === tokenRef.current) { + setLoading(false) + } + }) + }, debounceMs) + }, + [cancelTimer, debounceMs, enabled, fetcher, toItem] + ) + + const adapter = useMemo<Unstable_TriggerAdapter>( + () => ({ + categories: () => [], + categoryItems: () => [], + search: (query: string) => { + if (query !== state.query) { + scheduleFetch(query) + } + + return state.items + } + }), + [scheduleFetch, state] + ) + + return { adapter, loading } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts new file mode 100644 index 000000000..df74c7f4a --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState } from 'react' + +type BrowserAudioContext = typeof AudioContext + +export interface MicRecorderOptions { + onLevel?: (level: number) => void + onError?: (error: Error) => void + onSilence?: () => void + silenceLevel?: number + silenceMs?: number + idleSilenceMs?: number +} + +export interface MicRecording { + audio: Blob + durationMs: number + heardSpeech: boolean +} + +interface MicRecorderHandle { + start: (options?: MicRecorderOptions) => Promise<void> + stop: () => Promise<MicRecording | null> + cancel: () => void +} + +function micError(error: unknown): Error { + const name = error instanceof DOMException ? error.name : '' + + if (name === 'NotAllowedError' || name === 'SecurityError') { + return new Error('Microphone permission was denied.') + } + + if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { + return new Error('No microphone was found.') + } + + if (name === 'NotReadableError' || name === 'TrackStartError') { + return new Error('Microphone is already in use by another app.') + } + + if (name === 'OverconstrainedError') { + return new Error('Microphone constraints are not supported by this device.') + } + + if (error instanceof Error) { + return error + } + + return new Error('Could not start microphone recording.') +} + +export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } { + const [level, setLevel] = useState(0) + const [recording, setRecording] = useState(false) + + const recorderRef = useRef<MediaRecorder | null>(null) + const streamRef = useRef<MediaStream | null>(null) + const chunksRef = useRef<Blob[]>([]) + const audioContextRef = useRef<AudioContext | null>(null) + const animationRef = useRef<number | null>(null) + const startedAtRef = useRef(0) + const heardSpeechRef = useRef(false) + const silenceTriggeredRef = useRef(false) + const silenceStartedAtRef = useRef<number | null>(null) + const stopResolverRef = useRef<((recording: MicRecording | null) => void) | null>(null) + + const cleanup = () => { + if (animationRef.current) { + window.cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + + void audioContextRef.current?.close() + audioContextRef.current = null + streamRef.current?.getTracks().forEach(track => track.stop()) + streamRef.current = null + recorderRef.current = null + setLevel(0) + setRecording(false) + silenceTriggeredRef.current = false + } + + useEffect(() => () => cleanup(), []) + + const startMeter = (stream: MediaStream, options: MicRecorderOptions) => { + const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext } + const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext + + if (!AudioContextCtor) { + return + } + + try { + const audioContext = new AudioContextCtor() + const analyser = audioContext.createAnalyser() + const source = audioContext.createMediaStreamSource(stream) + + analyser.fftSize = 256 + const data = new Uint8Array(analyser.fftSize) + + source.connect(analyser) + audioContextRef.current = audioContext + + const tick = () => { + analyser.getByteTimeDomainData(data) + + let sum = 0 + + for (const value of data) { + const centered = value - 128 + sum += centered * centered + } + + const rms = Math.sqrt(sum / data.length) + const normalized = Math.min(1, rms / 42) + const now = Date.now() + + setLevel(normalized) + options.onLevel?.(normalized) + + const speechThreshold = options.silenceLevel ?? 0 + const silenceMs = options.silenceMs ?? 0 + const idleSilenceMs = options.idleSilenceMs ?? 0 + + if (speechThreshold > 0 && options.onSilence && !silenceTriggeredRef.current) { + if (normalized >= speechThreshold) { + heardSpeechRef.current = true + silenceStartedAtRef.current = null + } else if (heardSpeechRef.current && silenceMs > 0) { + silenceStartedAtRef.current ??= now + + if (now - silenceStartedAtRef.current >= silenceMs) { + silenceTriggeredRef.current = true + options.onSilence() + + return + } + } else if (!heardSpeechRef.current && idleSilenceMs > 0 && now - startedAtRef.current >= idleSilenceMs) { + silenceTriggeredRef.current = true + options.onSilence() + + return + } + } + + animationRef.current = window.requestAnimationFrame(tick) + } + + tick() + } catch { + setLevel(0) + } + } + + const start: MicRecorderHandle['start'] = async (options = {}) => { + if (recorderRef.current) { + return + } + + if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { + throw new Error('This runtime does not support microphone recording.') + } + + const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.() + + if (permitted === false) { + throw new Error('Microphone access denied.') + } + + let stream: MediaStream + + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true } + }) + } catch (error) { + throw micError(error) + } + + const mimeType = + ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav'].find( + type => MediaRecorder.isTypeSupported(type) + ) ?? '' + + let recorder: MediaRecorder + + try { + recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined) + } catch (error) { + stream.getTracks().forEach(track => track.stop()) + throw micError(error) + } + + chunksRef.current = [] + streamRef.current = stream + recorderRef.current = recorder + heardSpeechRef.current = false + silenceTriggeredRef.current = false + silenceStartedAtRef.current = null + startedAtRef.current = Date.now() + + recorder.ondataavailable = event => { + if (event.data.size > 0) { + chunksRef.current.push(event.data) + } + } + + recorder.onstop = () => { + const chunks = chunksRef.current + const recordingType = recorder.mimeType || mimeType || 'audio/webm' + const durationMs = Date.now() - startedAtRef.current + const heardSpeech = heardSpeechRef.current + + chunksRef.current = [] + cleanup() + + const resolver = stopResolverRef.current + stopResolverRef.current = null + + if (!chunks.length) { + resolver?.(null) + + return + } + + resolver?.({ + audio: new Blob(chunks, { type: recordingType }), + durationMs, + heardSpeech + }) + } + + recorder.onerror = event => { + const error = micError((event as Event & { error?: unknown }).error) + const resolver = stopResolverRef.current + stopResolverRef.current = null + cleanup() + options.onError?.(error) + resolver?.(null) + } + + recorder.start() + setRecording(true) + startMeter(stream, options) + } + + const stop: MicRecorderHandle['stop'] = () => + new Promise<MicRecording | null>(resolve => { + const recorder = recorderRef.current + + if (!recorder || recorder.state === 'inactive') { + cleanup() + resolve(null) + + return + } + + stopResolverRef.current = resolve + recorder.stop() + }) + + const cancel: MicRecorderHandle['cancel'] = () => { + const recorder = recorderRef.current + const resolver = stopResolverRef.current + stopResolverRef.current = null + + if (recorder && recorder.state !== 'inactive') { + recorder.ondataavailable = null + recorder.onerror = null + recorder.onstop = null + recorder.stop() + } + + cleanup() + resolver?.(null) + } + + const handle: MicRecorderHandle = { start, stop, cancel } + + return { handle, level, recording } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts new file mode 100644 index 000000000..62c982d15 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -0,0 +1,107 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useCallback } from 'react' + +import type { HermesGateway } from '@/hermes' +import { + type CommandsCatalogLike, + desktopSlashDescription, + filterDesktopCommandsCatalog, + isDesktopSlashSuggestion +} from '@/lib/desktop-slash-commands' + +import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter' +import { useLiveCompletionAdapter } from './use-live-completion-adapter' + +interface SlashItemMetadata extends Record<string, string> { + command: string + display: string + meta: string +} + +function textValue(value: unknown, fallback = ''): string { + if (typeof value === 'string') { + return value + } + + if (Array.isArray(value)) { + return value + .map(part => (Array.isArray(part) ? String(part[1] ?? '') : typeof part === 'string' ? part : '')) + .join('') + .trim() + } + + return fallback +} + +function commandText(value: string): string { + return value.startsWith('/') ? value : `/${value}` +} + +/** Live `/` completions backed by the gateway's `complete.slash` RPC. */ +export function useSlashCompletions(options: { gateway: HermesGateway | null }): { + adapter: Unstable_TriggerAdapter + loading: boolean +} { + const { gateway } = options + const enabled = Boolean(gateway) + + const fetcher = useCallback( + async (query: string): Promise<CompletionPayload> => { + if (!gateway) { + return { items: [], query } + } + + const text = `/${query}` + + try { + if (!query) { + const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog')) + + const items = (catalog.pairs ?? []).map(([command, meta]) => ({ + text: command, + display: command, + meta + })) + + return { items, query } + } + + const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text }) + + const items = (result.items ?? []) + .filter(item => isDesktopSlashSuggestion(item.text)) + .map(item => ({ + ...item, + meta: desktopSlashDescription(item.text, textValue(item.meta)) + })) + + return { items, query } + } catch { + return { items: [], query } + } + }, + [gateway] + ) + + const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => { + const command = commandText(entry.text) + const display = textValue(entry.display, commandText(entry.text)) + const meta = textValue(entry.meta) + + const metadata: SlashItemMetadata = { + command, + display, + meta + } + + return { + id: `${entry.text}|${index}`, + type: 'slash', + label: display.startsWith('/') ? display.slice(1) : display, + ...(meta ? { description: meta } : {}), + metadata + } + }, []) + + return useLiveCompletionAdapter({ enabled, fetcher, toItem }) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts new file mode 100644 index 000000000..3261acc34 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -0,0 +1,387 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' +import { notify, notifyError } from '@/store/notifications' + +import { useMicRecorder } from './use-mic-recorder' + +export type ConversationStatus = 'idle' | 'listening' | 'transcribing' | 'thinking' | 'speaking' + +interface PendingVoiceResponse { + id: string + pending: boolean + text: string +} + +interface VoiceConversationOptions { + busy: boolean + enabled: boolean + onFatalError?: () => void + onSubmit: (text: string) => Promise<void> | void + onTranscribeAudio?: (audio: Blob) => Promise<string> + pendingResponse: () => PendingVoiceResponse | null + consumePendingResponse: () => void +} + +export function useVoiceConversation({ + busy, + enabled, + onFatalError, + onSubmit, + onTranscribeAudio, + pendingResponse, + consumePendingResponse +}: VoiceConversationOptions) { + const { handle, level } = useMicRecorder() + const [status, setStatus] = useState<ConversationStatus>('idle') + const [muted, setMuted] = useState(false) + const turnTimeoutRef = useRef<number | null>(null) + const pendingStartRef = useRef(false) + const turnClosingRef = useRef(false) + const awaitingSpokenResponseRef = useRef(false) + const responseIdRef = useRef<string | null>(null) + const spokenSourceLengthRef = useRef(0) + const speechBufferRef = useRef('') + const enabledRef = useRef(enabled) + const mutedRef = useRef(muted) + const busyRef = useRef(busy) + const statusRef = useRef<ConversationStatus>('idle') + const wasEnabledRef = useRef(enabled) + + useEffect(() => { + enabledRef.current = enabled + }, [enabled]) + + useEffect(() => { + mutedRef.current = muted + }, [muted]) + + useEffect(() => { + busyRef.current = busy + }, [busy]) + + useEffect(() => { + statusRef.current = status + }, [status]) + + const clearTurnTimeout = () => { + if (turnTimeoutRef.current) { + window.clearTimeout(turnTimeoutRef.current) + turnTimeoutRef.current = null + } + } + + const resetSpeechBuffer = () => { + responseIdRef.current = null + spokenSourceLengthRef.current = 0 + speechBufferRef.current = '' + } + + const appendSpeechText = (text: string) => { + if (!text) { + return + } + + speechBufferRef.current = `${speechBufferRef.current}${text}` + } + + const takeSpeechChunk = (force = false): string | null => { + const buffer = speechBufferRef.current.replace(/\s+/g, ' ').trim() + + if (!buffer) { + speechBufferRef.current = '' + + return null + } + + const sentence = buffer.match(/^(.+?[.!?。!?])(?:\s+|$)/) + + if (sentence?.[1] && (sentence[1].length >= 8 || force)) { + const chunk = sentence[1].trim() + speechBufferRef.current = buffer.slice(sentence[1].length).trim() + + return chunk + } + + if (!force && buffer.length > 220) { + const softBoundary = Math.max( + buffer.lastIndexOf(', ', 180), + buffer.lastIndexOf('; ', 180), + buffer.lastIndexOf(': ', 180) + ) + + if (softBoundary > 80) { + const chunk = buffer.slice(0, softBoundary + 1).trim() + speechBufferRef.current = buffer.slice(softBoundary + 1).trim() + + return chunk + } + } + + if (!force) { + return null + } + + speechBufferRef.current = '' + + return buffer + } + + const handleTurn = useCallback( + async (forceTranscribe = false) => { + if (turnClosingRef.current) { + return + } + + turnClosingRef.current = true + clearTurnTimeout() + setStatus('transcribing') + + try { + const result = await handle.stop() + + if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) { + if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') { + pendingStartRef.current = true + } + + setStatus('idle') + + return + } + + try { + const transcript = (await onTranscribeAudio(result.audio)).trim() + + if (!transcript) { + if (enabledRef.current) { + pendingStartRef.current = true + } + + setStatus('idle') + + return + } + + awaitingSpokenResponseRef.current = true + resetSpeechBuffer() + await onSubmit(transcript) + setStatus('thinking') + } catch (error) { + notifyError(error, 'Voice transcription failed') + + if (enabledRef.current && !mutedRef.current && !busyRef.current) { + pendingStartRef.current = true + } + + setStatus('idle') + } + } finally { + turnClosingRef.current = false + } + }, + [handle, onSubmit, onTranscribeAudio] + ) + + const startListening = useCallback(async () => { + pendingStartRef.current = false + + if (!enabledRef.current || mutedRef.current || busyRef.current) { + return + } + + if (statusRef.current !== 'idle') { + return + } + + try { + // VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI. + await handle.start({ + silenceLevel: 0.075, + silenceMs: 1_250, + idleSilenceMs: 12_000, + onError: error => { + notifyError(error, 'Microphone failed') + pendingStartRef.current = false + onFatalError?.() + }, + onSilence: () => void handleTurn() + }) + setStatus('listening') + turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000) + } catch (error) { + notifyError(error, 'Could not start voice session') + pendingStartRef.current = false + setStatus('idle') + onFatalError?.() + } + }, [handle, handleTurn, onFatalError]) + + const speak = useCallback(async (text: string) => { + setStatus('speaking') + + try { + await playSpeechText(text, { source: 'voice-conversation' }) + } catch (error) { + notifyError(error, 'Voice playback failed') + } finally { + if (enabledRef.current) { + pendingStartRef.current = true + setStatus('idle') + } else { + setStatus('idle') + } + } + }, []) + + const start = useCallback(async () => { + if (!onTranscribeAudio) { + notify({ + kind: 'warning', + title: 'Voice unavailable', + message: 'Configure speech-to-text to use voice mode.' + }) + onFatalError?.() + + return + } + + setMuted(false) + awaitingSpokenResponseRef.current = false + resetSpeechBuffer() + consumePendingResponse() + pendingStartRef.current = true + await startListening() + }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening]) + + const end = useCallback(async () => { + pendingStartRef.current = false + clearTurnTimeout() + stopVoicePlayback() + handle.cancel() + turnClosingRef.current = false + awaitingSpokenResponseRef.current = false + resetSpeechBuffer() + consumePendingResponse() + setMuted(false) + setStatus('idle') + }, [consumePendingResponse, handle]) + + const stopTurn = useCallback(() => { + if (statusRef.current === 'listening') { + void handleTurn(true) + } + }, [handleTurn]) + + const toggleMute = useCallback(() => { + setMuted(value => { + const next = !value + + if (next) { + clearTurnTimeout() + handle.cancel() + setStatus('idle') + } else if (enabledRef.current && !busyRef.current && statusRef.current === 'idle') { + pendingStartRef.current = true + } + + return next + }) + }, [handle]) + + useEffect(() => { + if (!enabled) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code !== 'Space' || event.repeat || event.metaKey || event.ctrlKey || event.altKey) { + return + } + + if (statusRef.current !== 'listening') { + return + } + + event.preventDefault() + stopTurn() + } + + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [enabled, stopTurn]) + + // Drive the loop: after a voice-submitted turn, speak stable chunks as the + // assistant stream grows. Otherwise start listening when idle between turns. + useEffect(() => { + if (!enabled || muted) { + return + } + + if (awaitingSpokenResponseRef.current && status !== 'speaking') { + const response = pendingResponse() + + if (response) { + if (response.id !== responseIdRef.current) { + resetSpeechBuffer() + responseIdRef.current = response.id + } + + if (response.text.length > spokenSourceLengthRef.current) { + appendSpeechText(response.text.slice(spokenSourceLengthRef.current)) + spokenSourceLengthRef.current = response.text.length + } + + const chunk = takeSpeechChunk(!response.pending && !busy) + + if (chunk) { + void speak(chunk) + + return + } + + if (!response.pending && !busy) { + awaitingSpokenResponseRef.current = false + consumePendingResponse() + resetSpeechBuffer() + pendingStartRef.current = true + setStatus('idle') + + return + } + } + + if (!busy && status === 'thinking') { + awaitingSpokenResponseRef.current = false + resetSpeechBuffer() + pendingStartRef.current = true + setStatus('idle') + + return + } + } + + if (busy || status !== 'idle') { + return + } + + if (pendingStartRef.current) { + void startListening() + } + }, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status]) + + useEffect(() => { + if (enabled && !wasEnabledRef.current) { + void start() + } + + if (!enabled && wasEnabledRef.current) { + void end() + } + + wasEnabledRef.current = enabled + }, [enabled, end, start]) + + return { end, level, muted, start, status, stopTurn, toggleMute } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts new file mode 100644 index 000000000..cffc2820c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react' + +import { notify, notifyError } from '@/store/notifications' + +import type { VoiceActivityState, VoiceStatus } from '../types' + +import { useMicRecorder } from './use-mic-recorder' + +interface VoiceRecorderOptions { + maxRecordingSeconds: number + onTranscribeAudio?: (audio: Blob) => Promise<string> + focusInput: () => void + onTranscript: (text: string) => void +} + +export function useVoiceRecorder({ + maxRecordingSeconds, + onTranscribeAudio, + focusInput, + onTranscript +}: VoiceRecorderOptions) { + const { handle, level, recording } = useMicRecorder() + const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>('idle') + const [elapsedSeconds, setElapsedSeconds] = useState(0) + const startedAtRef = useRef(0) + const intervalRef = useRef<number | null>(null) + const timeoutRef = useRef<number | null>(null) + + const clearTimers = () => { + if (intervalRef.current) { + window.clearInterval(intervalRef.current) + intervalRef.current = null + } + + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + + useEffect(() => () => clearTimers(), []) + + const stop = async () => { + clearTimers() + const result = await handle.stop() + + if (!result) { + setVoiceStatus('idle') + + return + } + + if (!onTranscribeAudio) { + setVoiceStatus('idle') + + return + } + + setVoiceStatus('transcribing') + + try { + const transcript = (await onTranscribeAudio(result.audio)).trim() + + if (!transcript) { + notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' }) + } else { + onTranscript(transcript) + } + } catch (error) { + notifyError(error, 'Voice transcription failed') + } finally { + setVoiceStatus('idle') + focusInput() + } + } + + const start = async () => { + if (!onTranscribeAudio) { + notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' }) + + return + } + + try { + await handle.start({ onError: error => notifyError(error, 'Voice recording failed') }) + startedAtRef.current = Date.now() + setElapsedSeconds(0) + setVoiceStatus('recording') + intervalRef.current = window.setInterval(() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000), 250) + const cap = Math.max(1, Math.min(Math.trunc(maxRecordingSeconds), 600)) + timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000) + } catch (error) { + setVoiceStatus('idle') + notifyError(error, 'Voice recording failed') + } + } + + const dictate = () => { + if (recording) { + void stop() + } else if (voiceStatus === 'idle') { + void start() + } + } + + const voiceActivityState: VoiceActivityState = { + elapsedSeconds, + level, + status: voiceStatus + } + + return { dictate, voiceActivityState, voiceStatus } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx new file mode 100644 index 000000000..a0b1a370b --- /dev/null +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -0,0 +1,1237 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { + type ClipboardEvent, + type FormEvent, + type KeyboardEvent, + type DragEvent as ReactDragEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' + +import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' +import { Button } from '@/components/ui/button' +import { useMediaQuery } from '@/hooks/use-media-query' +import { useResizeObserver } from '@/hooks/use-resize-observer' +import { chatMessageText } from '@/lib/chat-messages' +import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' +import { + $composerAttachments, + clearComposerAttachments, + type ComposerAttachment +} from '@/store/composer' +import { + $queuedPromptsBySession, + enqueueQueuedPrompt, + type QueuedPromptEntry, + removeQueuedPrompt, + updateQueuedPrompt +} from '@/store/composer-queue' +import { $messages } from '@/store/session' +import { $threadScrolledUp } from '@/store/thread-scroll' + +import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions' + +import { AttachmentList } from './attachments' +import { ContextMenu } from './context-menu' +import { ComposerControls } from './controls' +import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' +import { + type ComposerInsertMode, + focusComposerInput, + markActiveComposer, + onComposerFocusRequest, + onComposerInsertRequest +} from './focus' +import { HelpHint } from './help-hint' +import { useAtCompletions } from './hooks/use-at-completions' +import { useSlashCompletions } from './hooks/use-slash-completions' +import { useVoiceConversation } from './hooks/use-voice-conversation' +import { useVoiceRecorder } from './hooks/use-voice-recorder' +import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs' +import { QueuePanel } from './queue-panel' +import { + composerPlainText, + placeCaretEnd, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' +import { SkinSlashPopover } from './skin-slash-popover' +import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' +import { ComposerTriggerPopover } from './trigger-popover' +import type { ChatBarProps } from './types' +import { UrlDialog } from './url-dialog' +import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' + +const COMPOSER_STACK_BREAKPOINT_PX = 320 + +const COMPOSER_FADE_BACKGROUND = + 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' + +interface QueueEditState { + attachments: ComposerAttachment[] + draft: string + entryId: string + sessionKey: string +} + +const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) + +export function ChatBar({ + busy, + cwd, + disabled, + focusKey, + gateway, + maxRecordingSeconds = 120, + queueSessionKey, + sessionId, + state, + onCancel, + onAddUrl, + onAttachDroppedItems, + onAttachImageBlob, + onPasteClipboardImage, + onPickFiles, + onPickFolders, + onPickImages, + onRemoveAttachment, + onSubmit, + onTranscribeAudio +}: ChatBarProps) { + const aui = useAui() + const draft = useAuiState(s => s.composer.text) + const attachments = useStore($composerAttachments) + const queuedPromptsBySession = useStore($queuedPromptsBySession) + const scrolledUp = useStore($threadScrolledUp) + const activeQueueSessionKey = queueSessionKey || sessionId || null + + const queuedPrompts = useMemo( + () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), + [activeQueueSessionKey, queuedPromptsBySession] + ) + + const composerRef = useRef<HTMLFormElement | null>(null) + const composerSurfaceRef = useRef<HTMLDivElement | null>(null) + const editorRef = useRef<HTMLDivElement | null>(null) + const draftRef = useRef(draft) + const previousBusyRef = useRef(busy) + const drainingQueueRef = useRef(false) + const urlInputRef = useRef<HTMLInputElement | null>(null) + + const [urlOpen, setUrlOpen] = useState(false) + const [urlValue, setUrlValue] = useState('') + const [expanded, setExpanded] = useState(false) + const [voiceConversationActive, setVoiceConversationActive] = useState(false) + const [tight, setTight] = useState(false) + const [dragActive, setDragActive] = useState(false) + const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null) + const [focusRequestId, setFocusRequestId] = useState(0) + const dragDepthRef = useRef(0) + const lastSpokenIdRef = useRef<string | null>(null) + + const narrow = useMediaQuery('(max-width: 30rem)') + + const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) + const slash = useSlashCompletions({ gateway: gateway ?? null }) + + const stacked = expanded || narrow || tight + const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0 + const canSubmit = busy || hasComposerPayload + const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null + const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' + const showHelpHint = draft === '?' + + const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up' + + const focusInput = useCallback(() => { + focusComposerInput(editorRef.current) + markActiveComposer('main') + }, []) + + const requestMainFocus = useCallback(() => { + setFocusRequestId(id => id + 1) + }, []) + + const appendExternalText = useCallback( + (text: string, mode: ComposerInsertMode) => { + const value = text.trim() + + if (!value) { + return + } + + const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current + const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' + const next = `${base}${sep}${value}` + + draftRef.current = next + aui.composer().setText(next) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, next) + placeCaretEnd(editor) + } + + setFocusRequestId(id => id + 1) + }, + [aui] + ) + + useEffect(() => { + if (!disabled) { + focusInput() + } + }, [disabled, focusInput, focusKey, focusRequestId]) + + useEffect(() => { + if (disabled) { + return undefined + } + + const offFocus = onComposerFocusRequest(target => { + if (target === 'main') { + setFocusRequestId(id => id + 1) + } + }) + + const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + if (target === 'main') { + appendExternalText(text, mode) + } + }) + + return () => { + offFocus() + offInsert() + } + }, [appendExternalText, disabled]) + + // Keep draftRef in sync with the assistant-ui composer state for callers + // that read the latest text outside the React render cycle. We don't push + // to `$composerDraft` per keystroke any more — nobody outside the composer + // subscribes to it (verified by grep), and the round-trip + // `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per- + // keystroke critical path. `reconcileComposerTerminalSelections` only + // matters when the draft is submitted; we now call it from the submit + // path instead. + useEffect(() => { + draftRef.current = draft + + const editor = editorRef.current + + if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) { + renderComposerContents(editor, draft) + } + }, [draft]) + + useEffect(() => { + if (urlOpen) { + window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) + } + }, [urlOpen]) + + // Track expansion via cheap heuristics (newline or length threshold) instead + // of reading editor.scrollHeight on every keystroke. scrollHeight forces a + // synchronous layout flush — measured at 2.27 layouts per character typed + // (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on + // composer-default-width, this heuristic flips at roughly the right time + // and the user only notices if they type far past the wrap boundary + // without a newline; in that case the ResizeObserver below catches it via + // a height delta and we still expand. + useEffect(() => { + if (!draft) { + setExpanded(false) + + return + } + + if (expanded) { + return + } + + if (draft.includes('\n') || draft.length > 60) { + setExpanded(true) + } + }, [draft, expanded]) + + // Bucket measured heights so we only invalidate the global CSS var when + // the size crosses a meaningful threshold. Without bucketing, the editor + // grows ~1px per character → setProperty fires every keystroke → entire + // tree's computed style is invalidated → next paint forces a full + // recalculate-style pass. With an 8px bucket, the invalidation rate drops + // ~8× and small char-by-char typing produces no style invalidation at all + // until a wrap or row change actually happens. + const lastBucketedHeightRef = useRef(0) + const lastBucketedSurfaceHeightRef = useRef(0) + const lastTightRef = useRef<boolean | null>(null) + + const syncComposerMetrics = useCallback(() => { + const composer = composerRef.current + + if (!composer) { + return + } + + const { height, width } = composer.getBoundingClientRect() + const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height + const root = document.documentElement + + if (width > 0) { + const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX + + if (nextTight !== lastTightRef.current) { + lastTightRef.current = nextTight + setTight(nextTight) + } + } + + if (height > 0) { + const bucket = Math.round(height / 8) * 8 + + if (bucket !== lastBucketedHeightRef.current) { + lastBucketedHeightRef.current = bucket + root.style.setProperty('--composer-measured-height', `${bucket}px`) + } + } + + if (surfaceHeight && surfaceHeight > 0) { + const bucket = Math.round(surfaceHeight / 8) * 8 + + if (bucket !== lastBucketedSurfaceHeightRef.current) { + lastBucketedSurfaceHeightRef.current = bucket + root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) + } + } + }, []) + + useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef) + + useEffect(() => { + return () => { + const root = document.documentElement + root.style.removeProperty('--composer-measured-height') + root.style.removeProperty('--composer-surface-measured-height') + } + }, []) + + const insertText = (text: string) => { + const currentDraft = draftRef.current + const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : '' + const nextDraft = `${currentDraft}${sep}${text}` + + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + + // Push the new text into the contentEditable editor directly. Setting the + // assistant-ui composer state alone is not enough: the draft→editor sync + // effect only re-renders the editor when it is NOT focused + // (document.activeElement !== editor), and the dictation/insert paths + // typically run while the editor has (or immediately regains) focus — so + // the store would hold the text but the visible editor would stay empty + // and there'd be nothing to send. Mirror appendExternalText here. + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, nextDraft) + placeCaretEnd(editor) + } + + requestMainFocus() + } + + const insertInlineRefs = (refs: string[]) => { + const editor = editorRef.current + + if (!editor) { + return false + } + + const nextDraft = insertInlineRefsIntoEditor(editor, refs) + + if (nextDraft === null) { + return false + } + + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + requestMainFocus() + + return true + } + + const selectSkinSlashCommand = (command: string) => { + draftRef.current = command + aui.composer().setText(command) + requestMainFocus() + } + + const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => { + const imageBlobs = extractClipboardImageBlobs(event.clipboardData) + + if (imageBlobs.length > 0) { + event.preventDefault() + + if (onAttachImageBlob) { + triggerHaptic('selection') + + for (const blob of imageBlobs) { + void onAttachImageBlob(blob) + } + } + + return + } + + const pastedText = event.clipboardData.getData('text') + + if (!pastedText) { + return + } + + if (DATA_IMAGE_URL_RE.test(pastedText.trim())) { + event.preventDefault() + + return + } + + event.preventDefault() + document.execCommand('insertText', false, pastedText) + const nextDraft = composerPlainText(event.currentTarget) + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + } + + const [trigger, setTrigger] = useState<TriggerState | null>(null) + const [triggerActive, setTriggerActive] = useState(0) + const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([]) + + const refreshTrigger = useCallback(() => { + const editor = editorRef.current + + if (!editor) { + return + } + + // Fast-bail: if neither `@` nor `/` appears in the current draft, there's + // nothing for `detectTrigger` to match. Use `textContent` (cheap browser- + // native walk) for the precondition check rather than `composerPlainText` + // (recursive child walk with chip-aware logic). Only when a trigger char + // is present do we pay the cost of the full walk + DOM range work. + const rawText = editor.textContent ?? '' + + if (!rawText.includes('@') && !rawText.includes('/')) { + if (trigger) { + setTrigger(null) + setTriggerActive(0) + } + + return + } + + const before = textBeforeCaret(editor) + const detected = detectTrigger(before ?? composerPlainText(editor)) + + setTrigger(detected) + setTriggerActive(0) + }, [trigger]) + + const handleEditorInput = (event: FormEvent<HTMLDivElement>) => { + const editor = event.currentTarget + + if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { + editor.replaceChildren() + } + + const nextDraft = composerPlainText(editor) + + if (nextDraft !== draftRef.current) { + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + } + + window.setTimeout(refreshTrigger, 0) + } + + const triggerAdapter: Unstable_TriggerAdapter | null = + trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null + + useEffect(() => { + if (!trigger || !triggerAdapter?.search) { + setTriggerItems([]) + + return + } + + setTriggerItems(triggerAdapter.search(trigger.query)) + }, [trigger, triggerAdapter]) + + const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false + + const closeTrigger = () => { + setTrigger(null) + setTriggerItems([]) + setTriggerActive(0) + } + + useEffect(() => { + setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1))) + }, [triggerItems.length]) + + const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { + const editor = editorRef.current + + if (!editor || !trigger) { + return + } + + const serialized = hermesDirectiveFormatter.serialize(item) + const starter = serialized.endsWith(':') + const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` + const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) + + const finish = () => { + draftRef.current = composerPlainText(editor) + aui.composer().setText(draftRef.current) + requestMainFocus() + starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger() + } + + const sel = window.getSelection() + const range = sel?.rangeCount ? sel.getRangeAt(0) : null + const node = range?.startContainer + const offset = range?.startOffset ?? 0 + + if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) { + const current = composerPlainText(editor) + renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`) + placeCaretEnd(editor) + + return finish() + } + + const replaceRange = document.createRange() + replaceRange.setStart(node, offset - trigger.tokenLength) + replaceRange.setEnd(node, offset) + replaceRange.deleteContents() + + if (directive) { + const chip = refChipElement(directive[1], directive[2]) + const space = document.createTextNode(' ') + const fragment = document.createDocumentFragment() + fragment.append(chip, space) + replaceRange.insertNode(fragment) + + const caret = document.createRange() + caret.setStart(space, 1) + caret.collapse(true) + sel.removeAllRanges() + sel.addRange(caret) + + return finish() + } + + document.execCommand('insertText', false, text) + finish() + } + + const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') { + event.preventDefault() + + if (!busy) { + void drainNextQueued() + } + + return + } + + if (trigger && triggerItems.length > 0) { + if (event.key === 'ArrowDown') { + event.preventDefault() + setTriggerActive(idx => (idx + 1) % triggerItems.length) + + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) + + return + } + + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault() + const item = triggerItems[triggerActive] + + if (item) { + replaceTriggerWithChip(item) + } + + return + } + + if (event.key === 'Escape') { + event.preventDefault() + closeTrigger() + + return + } + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + + if (!busy && !hasComposerPayload && queuedPrompts.length > 0) { + void drainNextQueued() + + return + } + + submitDraft() + } + } + + const handleEditorKeyUp = () => { + window.setTimeout(refreshTrigger, 0) + } + + const resetDragState = () => { + dragDepthRef.current = 0 + setDragActive(false) + } + + const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + dragDepthRef.current += 1 + + if (!dragActive) { + setDragActive(true) + } + } + + const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + + if (dragDepthRef.current === 0) { + setDragActive(false) + } + } + + const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + resetDragState() + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (candidates.length === 0) { + return + } + + if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) { + const refs = candidates + .map(candidate => droppedFileInlineRef(candidate, cwd)) + .filter((ref): ref is string => Boolean(ref)) + + if (insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + return + } + + void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + + const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'copy' + } + + const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + const candidates = extractDroppedFiles(event.dataTransfer) + + const refs = candidates + .map(candidate => droppedFileInlineRef(candidate, cwd)) + .filter((ref): ref is string => Boolean(ref)) + + if (!refs.length) { + return + } + + event.preventDefault() + event.stopPropagation() + resetDragState() + + if (insertInlineRefs(refs)) { + triggerHaptic('selection') + } + } + + const clearDraft = useCallback(() => { + aui.composer().setText('') + draftRef.current = '' + + if (editorRef.current) { + editorRef.current.replaceChildren() + } + }, [aui]) + + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { + draftRef.current = text + aui.composer().setText(text) + $composerAttachments.set(cloneAttachments(attachments)) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, text) + placeCaretEnd(editor) + } + } + + const beginQueuedEdit = (entry: QueuedPromptEntry) => { + if (!activeQueueSessionKey || queueEdit) { + return + } + + setQueueEdit({ + attachments: cloneAttachments($composerAttachments.get()), + draft: draftRef.current, + entryId: entry.id, + sessionKey: activeQueueSessionKey + }) + loadIntoComposer(entry.text, entry.attachments) + triggerHaptic('selection') + focusInput() + } + + const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { + if (!queueEdit) { + return false + } + + if (action === 'save') { + const text = draftRef.current + const next = cloneAttachments($composerAttachments.get()) + + if (!text.trim() && next.length === 0) { + return false + } + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) + triggerHaptic(saved ? 'success' : 'selection') + } else { + triggerHaptic('cancel') + } + + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + setQueueEdit(null) + focusInput() + + return true + } + + const queueCurrentDraft = useCallback(() => { + if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) { + return false + } + + if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) { + return false + } + + clearDraft() + clearComposerAttachments() + triggerHaptic('selection') + + return true + }, [activeQueueSessionKey, attachments, clearDraft, draft]) + + // All queue drain paths share one lock + send-then-remove sequence. + // `pickEntry` lets each caller choose head, by-id, or skip-edited. + const runDrain = useCallback( + async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => { + if (drainingQueueRef.current || !activeQueueSessionKey) { + return false + } + + const entry = pickEntry(queuedPrompts) + + if (!entry) { + return false + } + + drainingQueueRef.current = true + + try { + const accepted = await Promise.resolve( + onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) + ) + + if (accepted === false) { + return false + } + + removeQueuedPrompt(activeQueueSessionKey, entry.id) + + return true + } finally { + drainingQueueRef.current = false + } + }, + [activeQueueSessionKey, onSubmit, queuedPrompts] + ) + + const drainNextQueued = useCallback( + () => + runDrain(entries => { + const skip = queueEdit?.entryId + + return skip ? entries.find(e => e.id !== skip) : entries[0] + }), + [queueEdit, runDrain] + ) + + const sendQueuedNow = useCallback( + (id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)), + [queueEdit, runDrain] + ) + + const interruptAndSendNextQueued = useCallback(async () => { + if (queuedPrompts.length === 0) { + return false + } + + await Promise.resolve(onCancel()) + + return drainNextQueued() + }, [drainNextQueued, onCancel, queuedPrompts.length]) + + // Auto-drain on busy → false (turn settled). + useEffect(() => { + const wasBusy = previousBusyRef.current + previousBusyRef.current = busy + + if (busy || !wasBusy || queuedPrompts.length === 0) { + return + } + + void drainNextQueued() + }, [busy, drainNextQueued, queuedPrompts.length]) + + // Clean up queue edit when its target disappears (session swap or external delete). + useEffect(() => { + if (!queueEdit) { + return + } + + if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) { + return + } + + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + setQueueEdit(null) + }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps + + const submitDraft = () => { + if (queueEdit) { + exitQueuedEdit('save') + } else if (busy) { + if (hasComposerPayload) { + queueCurrentDraft() + } else if (queuedPrompts.length > 0) { + void interruptAndSendNextQueued() + } else { + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + } else if (!hasComposerPayload && queuedPrompts.length > 0) { + void drainNextQueued() + } else if (draft.trim() || attachments.length > 0) { + const submitted = draft + triggerHaptic('submit') + clearDraft() + void onSubmit(submitted) + } + + focusInput() + } + + const submitUrl = () => { + const url = urlValue.trim() + + if (!url) { + return + } + + if (onAddUrl) { + onAddUrl(url) + } else { + insertText(`@url:${url}`) + } + + triggerHaptic('success') + setUrlValue('') + setUrlOpen(false) + } + + const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ + focusInput, + maxRecordingSeconds, + onTranscript: insertText, + onTranscribeAudio + }) + + const pendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (!last || last.id === lastSpokenIdRef.current) { + return null + } + + const text = chatMessageText(last).trim() + + if (!text) { + return null + } + + return { + id: last.id, + pending: Boolean(last.pending), + text + } + } + + const consumePendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (last) { + lastSpokenIdRef.current = last.id + } + } + + const submitVoiceTurn = async (text: string) => { + if (busy) { + return + } + + triggerHaptic('submit') + clearDraft() + await onSubmit(text) + } + + const conversation = useVoiceConversation({ + busy, + consumePendingResponse, + enabled: voiceConversationActive, + onFatalError: () => setVoiceConversationActive(false), + onSubmit: submitVoiceTurn, + onTranscribeAudio, + pendingResponse + }) + + const contextMenu = ( + <ContextMenu + onInsertText={insertText} + onOpenUrlDialog={() => { + triggerHaptic('open') + setUrlOpen(true) + }} + onPasteClipboardImage={onPasteClipboardImage} + onPickFiles={onPickFiles} + onPickFolders={onPickFolders} + onPickImages={onPickImages} + state={state} + /> + ) + + const controls = ( + <ComposerControls + busy={busy} + busyAction={busyAction} + canSubmit={canSubmit} + conversation={{ + active: voiceConversationActive, + level: conversation.level, + muted: conversation.muted, + onEnd: () => { + setVoiceConversationActive(false) + void conversation.end() + }, + onStart: () => setVoiceConversationActive(true), + onStopTurn: conversation.stopTurn, + onToggleMute: conversation.toggleMute, + status: conversation.status + }} + disabled={disabled} + hasComposerPayload={hasComposerPayload} + onDictate={dictate} + state={state} + voiceStatus={voiceStatus} + /> + ) + + const input = ( + <div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}> + <div + aria-label="Message" + className={cn( + 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', + 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', + '**:data-ref-text:cursor-default', + stacked && 'pl-3', + stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1' + )} + contentEditable={!disabled} + data-placeholder={placeholder} + data-slot={RICH_INPUT_SLOT} + onBlur={() => window.setTimeout(closeTrigger, 80)} + onDragOver={handleInputDragOver} + onDrop={handleInputDrop} + onFocus={() => markActiveComposer('main')} + onInput={handleEditorInput} + onKeyDown={handleEditorKeyDown} + onKeyUp={handleEditorKeyUp} + onMouseUp={refreshTrigger} + onPaste={handlePaste} + ref={editorRef} + role="textbox" + suppressContentEditableWarning + /> + {/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree + so the composer-state binding (text + IME + paste + form-submit hookup) + wires up. We render the real input UI ourselves above via the + contentEditable, so the primitive is invisible (sr-only). + + IMPORTANT: don't let it render its default <TextareaAutosize>. That + component runs `useLayoutEffect(resizeTextarea)` on every value change + and reads `node.scrollHeight` against a hidden measurement textarea, + forcing two synchronous layouts per keystroke for an element the + user can't see. Profiling 400-char synthetic typing showed >900ms + cumulative cost in getHeight2/calculateNodeHeight alone (~2.3ms/key) + on top of the per-keystroke React commit. + + `asChild` swaps TextareaAutosize for a Radix Slot wrapping our + plain <textarea>, which carries the binding but skips autosize. */} + <ComposerPrimitive.Input asChild tabIndex={-1} unstable_focusOnScrollToBottom={false}> + <textarea aria-hidden className="sr-only" tabIndex={-1} /> + </ComposerPrimitive.Input> + </div> + ) + + return ( + <> + <ComposerPrimitive.Unstable_TriggerPopoverRoot> + <ComposerPrimitive.Root + className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]" + data-drag-active={dragActive ? '' : undefined} + data-slot="composer-root" + data-thread-scrolled-up={scrolledUp ? '' : undefined} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + onSubmit={e => { + e.preventDefault() + submitDraft() + }} + ref={composerRef} + > + {showHelpHint && <HelpHint />} + {trigger && ( + <ComposerTriggerPopover + activeIndex={triggerActive} + items={triggerItems} + kind={trigger.kind} + loading={triggerLoading} + onHover={setTriggerActive} + onPick={replaceTriggerWithChip} + /> + )} + <SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} /> + {activeQueueSessionKey && queuedPrompts.length > 0 && ( + <div className="relative z-6 mb-1 px-0.5"> + <QueuePanel + busy={busy} + editingId={queueEdit?.entryId ?? null} + entries={queuedPrompts} + onDelete={id => { + if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { + exitQueuedEdit('cancel') + } + }} + onEdit={beginQueuedEdit} + onSendNow={id => void sendQueuedNow(id)} + /> + </div> + )} + <div + className="pointer-events-none absolute inset-0 rounded-[inherit]" + style={{ background: COMPOSER_FADE_BACKGROUND }} + /> + <div className="relative w-full rounded-[inherit]"> + <div + className={cn( + 'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out', + COMPOSER_DROP_FADE_CLASS, + 'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)] group-focus-within/composer:shadow-composer-focus', + 'group-has-data-[state=open]/composer:border-t-transparent', + 'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-composer-ring)_calc(35%*var(--composer-ring-strength)),transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]', + dragActive && COMPOSER_DROP_ACTIVE_CLASS + )} + data-slot="composer-surface" + ref={composerSurfaceRef} + > + <div + aria-hidden + className={cn( + 'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]', + 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', + 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]', + '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', + 'transition-[background-color] duration-150 ease-out', + 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', + 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]' + )} + /> + <div + className={cn( + 'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out', + scrolledUp + ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100' + : 'opacity-100' + )} + data-slot="composer-fade" + > + <VoiceActivity state={voiceActivityState} /> + <VoicePlaybackActivity /> + {queueEdit && editingQueuedPrompt && ( + <div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1"> + <div className="min-w-0 text-[0.7rem] text-muted-foreground/88"> + Editing queued turn in composer + </div> + <div className="flex shrink-0 items-center gap-1"> + <Button + className="h-6 rounded-md px-2 text-[0.68rem]" + onClick={() => exitQueuedEdit('cancel')} + type="button" + variant="ghost" + > + Cancel + </Button> + <Button + className="h-6 rounded-md px-2 text-[0.68rem]" + onClick={() => exitQueuedEdit('save')} + type="button" + > + Save + </Button> + </div> + </div> + )} + {attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />} + <div + className={cn( + 'grid w-full', + stacked + ? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]' + : 'grid-cols-[auto_1fr_auto] items-end gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]' + )} + > + <div className="flex items-center [grid-area:menu]">{contextMenu}</div> + <div className="min-w-0 [grid-area:input]">{input}</div> + <div className="flex items-center justify-end [grid-area:controls]">{controls}</div> + </div> + </div> + </div> + </div> + </ComposerPrimitive.Root> + </ComposerPrimitive.Unstable_TriggerPopoverRoot> + + <UrlDialog + inputRef={urlInputRef} + onChange={setUrlValue} + onOpenChange={setUrlOpen} + onSubmit={submitUrl} + open={urlOpen} + value={urlValue} + /> + </> + ) +} + +export function ChatBarFallback() { + return ( + <div + className={cn( + 'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]', + 'bg-linear-to-b from-transparent to-background/55' + )} + data-slot="composer-root" + > + <div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer"> + <div + aria-hidden + className={cn( + 'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]', + 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', + 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]', + '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', + 'transition-[background-color] duration-150 ease-out', + 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', + 'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]' + )} + /> + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts new file mode 100644 index 000000000..bb59a9c68 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -0,0 +1,91 @@ +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { contextPath } from '@/lib/chat-runtime' + +import type { DroppedFile } from '../hooks/use-composer-actions' + +import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor' + +export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) { + if (!transfer) { + return false + } + + if (Array.from(transfer.types || []).includes(pathsMime)) { + return true + } + + if (Array.from(transfer.types || []).includes('Files')) { + return true + } + + return Array.from(transfer.items || []).some(item => item.kind === 'file') +} + +export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) { + if (!candidate.path) { + return null + } + + const rel = contextPath(candidate.path, cwd || '') + + if (candidate.line) { + const { line, lineEnd } = candidate + const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}` + + return `@line:${formatRefValue(`${rel}:${range}`)}` + } + + const kind = candidate.isDirectory ? 'folder' : 'file' + + return `@${kind}:${formatRefValue(rel)}` +} + +export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) { + if (!refs.length) { + return null + } + + const refsHtml = refs + .map(ref => { + const match = ref.match(/^@([^:]+):(.+)$/) + + return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref) + }) + .join(' ') + + const selection = window.getSelection() + + const range = + selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer) + ? selection.getRangeAt(0) + : null + + editor.focus({ preventScroll: true }) + + if (range) { + const beforeRange = range.cloneRange() + beforeRange.selectNodeContents(editor) + beforeRange.setEnd(range.startContainer, range.startOffset) + const beforeContainer = document.createElement('div') + beforeContainer.appendChild(beforeRange.cloneContents()) + + const afterRange = range.cloneRange() + afterRange.selectNodeContents(editor) + afterRange.setStart(range.endContainer, range.endOffset) + const afterContainer = document.createElement('div') + afterContainer.appendChild(afterRange.cloneContents()) + + const beforeText = composerPlainText(beforeContainer) + const afterText = composerPlainText(afterContainer) + const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText) + const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText) + + document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`) + } else { + const current = composerPlainText(editor) + placeCaretEnd(editor) + document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `) + } + + return composerPlainText(editor) +} diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx new file mode 100644 index 000000000..18e95d044 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { ArrowUp, Pencil, Trash2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import type { QueuedPromptEntry } from '@/store/composer-queue' + +interface QueuePanelProps { + busy: boolean + editingId: null | string + entries: QueuedPromptEntry[] + onDelete: (id: string) => void + onEdit: (entry: QueuedPromptEntry) => void + onSendNow: (id: string) => void +} + +const entryPreview = (entry: QueuedPromptEntry) => + entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn') + +export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) { + const [collapsed, setCollapsed] = useState(false) + + if (entries.length === 0) { + return null + } + + return ( + <div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]"> + <button + className="flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90" + onClick={() => setCollapsed(open => !open)} + type="button" + > + <DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" /> + <span className="truncate">{entries.length} Queued</span> + </button> + + {!collapsed && ( + <div className="space-y-0.5 px-1.5 pb-0.5"> + {entries.map(entry => { + const isEditing = editingId === entry.id + const attachmentsCount = entry.attachments.length + + return ( + <div + className={cn( + 'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1', + 'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none', + isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25' + )} + key={entry.id} + > + <span + aria-hidden + className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" + /> + <div className="min-w-0 flex-1"> + <p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p> + {(attachmentsCount > 0 || isEditing) && ( + <div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75"> + {attachmentsCount > 0 && ( + <span> + {attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'} + </span> + )} + {isEditing && ( + <span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]"> + Editing in composer + </span> + )} + </div> + )} + </div> + <div + className={cn( + 'flex shrink-0 items-center gap-0 transition-opacity', + isEditing + ? 'opacity-100' + : 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100' + )} + > + <Button + aria-label="Edit queued turn" + className="h-5 w-5 rounded-md" + disabled={Boolean(editingId) && !isEditing} + onClick={() => onEdit(entry)} + size="icon-xs" + title="Edit queued turn" + type="button" + variant="ghost" + > + <Pencil size={11} /> + </Button> + <Button + aria-label="Send queued turn now" + className="h-5 w-5 rounded-md" + disabled={busy || isEditing} + onClick={() => onSendNow(entry.id)} + size="icon-xs" + title="Send queued turn now" + type="button" + variant="ghost" + > + <ArrowUp size={11} /> + </Button> + <Button + aria-label="Delete queued turn" + className="h-5 w-5 rounded-md" + onClick={() => onDelete(entry.id)} + size="icon-xs" + title="Delete queued turn" + type="button" + variant="ghost" + > + <Trash2 size={11} /> + </Button> + </div> + </div> + ) + })} + </div> + )} + </div> + ) +} diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts new file mode 100644 index 000000000..c04e19a04 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' + +describe('renderComposerContents', () => { + it('renders refs and raw text without interpreting user text as HTML', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + + renderComposerContents(editor, '@file:`<img src=x onerror=alert(1)>` <b>raw</b>') + + expect(editor.querySelector('img')).toBeNull() + expect(editor.querySelector('b')).toBeNull() + expect(editor.textContent).toContain('<img src=x onerror=alert(1)>') + expect(editor.textContent).toContain('<b>raw</b>') + expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts new file mode 100644 index 000000000..3a45028e7 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -0,0 +1,165 @@ +/** + * Helpers for the contenteditable composer surface: serialize refs to chip + * HTML, walk the DOM back to plain `@kind:value` text, and place the caret. + * + * Chip values are always wrapped in backticks/quotes so REF_RE stops at the + * fence — without that, typing after a chip would get re-absorbed on the next + * plain-text round-trip. + */ +import { + DIRECTIVE_CHIP_CLASS, + directiveIconElement, + directiveIconSvg, + formatRefValue +} from '@/components/assistant-ui/directive-text' + +export const RICH_INPUT_SLOT = 'composer-rich-input' + +export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g + +const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } + +export function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, ch => ESC[ch] || ch) +} + +export function unquoteRef(raw: string) { + const head = raw[0] + const tail = raw[raw.length - 1] + const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'") + + return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '') +} + +export function refLabel(id: string) { + return id.split(/[\\/]/).filter(Boolean).pop() || id +} + +/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */ +export function quoteRefValue(value: string) { + if (!value.includes('`')) { + return `\`${value}\`` + } + + if (!value.includes('"')) { + return `"${value}"` + } + + if (!value.includes("'")) { + return `'${value}'` + } + + return formatRefValue(value) +} + +export function refChipHtml(kind: string, rawValue: string) { + const id = unquoteRef(rawValue) + const text = `@${kind}:${quoteRefValue(id)}` + + return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>` +} + +export function refChipElement(kind: string, rawValue: string) { + const id = unquoteRef(rawValue) + const text = `@${kind}:${quoteRefValue(id)}` + const chip = document.createElement('span') + const label = document.createElement('span') + + chip.contentEditable = 'false' + chip.dataset.refText = text + chip.dataset.refId = id + chip.dataset.refKind = kind + chip.className = DIRECTIVE_CHIP_CLASS + label.className = 'truncate' + label.textContent = refLabel(id) + chip.append(directiveIconElement(kind), label) + + return chip +} + +function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) { + const lines = text.split('\n') + + lines.forEach((line, index) => { + if (index > 0) { + target.append(document.createElement('br')) + } + + if (line) { + target.append(document.createTextNode(line)) + } + }) +} + +export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) { + let cursor = 0 + + REF_RE.lastIndex = 0 + + for (const match of text.matchAll(REF_RE)) { + const index = match.index ?? 0 + appendTextWithBreaks(target, text.slice(cursor, index)) + target.append(refChipElement(match[1] || 'file', match[2] || '')) + cursor = index + match[0].length + } + + appendTextWithBreaks(target, text.slice(cursor)) +} + +export function renderComposerContents(target: HTMLElement, text: string) { + target.replaceChildren() + appendComposerContents(target, text) +} + +/** Serialize a draft string into chip-HTML for the contenteditable surface. */ +export function composerHtml(text: string) { + let cursor = 0 + let html = '' + + REF_RE.lastIndex = 0 + + for (const match of text.matchAll(REF_RE)) { + const index = match.index ?? 0 + html += escapeHtml(text.slice(cursor, index)).replace(/\n/g, '<br>') + html += refChipHtml(match[1] || 'file', match[2] || '') + cursor = index + match[0].length + } + + return html + escapeHtml(text.slice(cursor)).replace(/\n/g, '<br>') +} + +/** Walk a DOM subtree back to the plain `@kind:value` text it represents. */ +export function composerPlainText(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || '' + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return '' + } + + const el = node as HTMLElement + + if (el.dataset.refText) { + return el.dataset.refText + } + + if (el.tagName === 'BR') { + return '\n' + } + + const text = Array.from(node.childNodes).map(composerPlainText).join('') + const block = el.tagName === 'DIV' || el.tagName === 'P' + + return block && text && el.dataset.slot !== RICH_INPUT_SLOT ? `${text}\n` : text +} + +export function placeCaretEnd(element: HTMLElement) { + const range = document.createRange() + const selection = window.getSelection() + + range.selectNodeContents(element) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) +} diff --git a/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx b/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx new file mode 100644 index 000000000..4e9dc5a78 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx @@ -0,0 +1,56 @@ +import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands' +import { triggerHaptic } from '@/lib/haptics' +import { useTheme } from '@/themes/context' + +import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer' + +interface SkinSlashPopoverProps { + draft: string + onSelect: (command: string) => void +} + +export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) { + const { availableThemes, themeName } = useTheme() + const match = draft.match(/^\/skin\s+(\S*)$/i) + + if (!match) { + return null + } + + const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '') + + return ( + <div + aria-label="Desktop theme suggestions" + className={COMPLETION_DRAWER_CLASS} + data-slot="composer-skin-completion-drawer" + data-state="open" + role="listbox" + > + <div className="grid gap-0.5 pt-0.5"> + {items.length === 0 ? ( + <CompletionDrawerEmpty title="No matching themes."> + Try <span className="font-mono text-foreground/80">/skin list</span>. + </CompletionDrawerEmpty> + ) : ( + items.map(item => ( + <button + className={COMPLETION_DRAWER_ROW_CLASS} + key={item.text} + onClick={() => { + triggerHaptic('selection') + onSelect(item.text) + }} + onMouseDown={event => event.preventDefault()} + role="option" + type="button" + > + <span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span> + <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span> + </button> + )) + )} + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts new file mode 100644 index 000000000..5725883d8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -0,0 +1,91 @@ +import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' + +export interface TriggerState { + kind: '@' | '/' + query: string + tokenLength: number +} + +const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/ + +export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { + const blobs: Blob[] = [] + const seen = new Set<Blob>() + + const push = (blob: Blob | null) => { + if (!blob || blob.size === 0 || seen.has(blob)) { + return + } + + seen.add(blob) + blobs.push(blob) + } + + if (clipboard.items?.length) { + for (const item of clipboard.items) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + push(item.getAsFile()) + } + } + } + + if (clipboard.files?.length) { + for (let i = 0; i < clipboard.files.length; i += 1) { + const file = clipboard.files.item(i) + + if (file && file.type.startsWith('image/')) { + push(file) + } + } + } + + if (blobs.length > 0) { + return blobs + } + + const text = clipboard.getData('text/plain').trim() + + if (DATA_IMAGE_URL_RE.test(text)) { + push(dataUrlToBlob(text)) + } + + if (blobs.length === 0) { + const html = clipboard.getData('text/html') + + if (html) { + const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi) + + for (const match of matches) { + push(dataUrlToBlob(match[1])) + } + } + } + + return blobs +} + +/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */ +export function textBeforeCaret(editor: HTMLDivElement): string | null { + const sel = window.getSelection() + const range = sel?.rangeCount ? sel.getRangeAt(0) : null + + if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) { + return null + } + + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + + return before.toString() +} + +export function detectTrigger(textBefore: string): TriggerState | null { + const match = TRIGGER_RE.exec(textBefore) + + if (!match) { + return null + } + + return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length } +} diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx new file mode 100644 index 000000000..7cc6a3b22 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -0,0 +1,112 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +import { + COMPLETION_DRAWER_BELOW_CLASS, + COMPLETION_DRAWER_CLASS, + COMPLETION_DRAWER_ROW_CLASS, + CompletionDrawerEmpty +} from './completion-drawer' + +const AT_ICON_BY_TYPE: Record<string, string> = { + diff: 'diff', + file: 'book', + folder: 'folder', + git: 'git-branch', + image: 'file-media', + simple: 'symbol-misc', + staged: 'diff-added', + tool: 'tools', + url: 'globe' +} + +function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) { + if (kind === '/') { + return 'terminal' + } + + const meta = item.metadata as { rawText?: string } | undefined + const raw = meta?.rawText || item.label + + if (raw.startsWith('@diff')) { + return AT_ICON_BY_TYPE.diff + } + + if (raw.startsWith('@staged')) { + return AT_ICON_BY_TYPE.staged + } + + return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple +} + +interface ComposerTriggerPopoverProps { + activeIndex: number + items: readonly Unstable_TriggerItem[] + kind: '@' | '/' + loading: boolean + onHover: (index: number) => void + onPick: (item: Unstable_TriggerItem) => void + placement?: 'bottom' | 'top' +} + +export function ComposerTriggerPopover({ + activeIndex, + items, + kind, + loading, + onHover, + onPick, + placement = 'top' +}: ComposerTriggerPopoverProps) { + return ( + <div + className={placement === 'bottom' ? COMPLETION_DRAWER_BELOW_CLASS : COMPLETION_DRAWER_CLASS} + data-slot="composer-completion-drawer" + data-state="open" + onMouseDown={event => event.preventDefault()} + role="listbox" + > + {items.length === 0 ? ( + <CompletionDrawerEmpty title={loading ? 'Looking up…' : 'No matches.'}> + {kind === '@' ? ( + <> + Try <span className="font-mono text-foreground/80">@file:</span> or{' '} + <span className="font-mono text-foreground/80">@folder:</span>. + </> + ) : ( + <> + Try <span className="font-mono text-foreground/80">/help</span>. + </> + )} + </CompletionDrawerEmpty> + ) : ( + items.map((item, index) => { + const meta = item.metadata as { display?: string; meta?: string } | undefined + const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label) + const description = meta?.meta || item.description + + return ( + <button + className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')} + data-highlighted={index === activeIndex ? '' : undefined} + key={item.id} + onClick={() => onPick(item)} + onMouseEnter={() => onHover(index)} + type="button" + > + <span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)"> + <Codicon name={completionIcon(kind, item)} size="0.875rem" /> + </span> + <span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span> + {description && ( + <span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span> + )} + </button> + ) + }) + )} + </div> + ) +} diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts new file mode 100644 index 000000000..524667e95 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -0,0 +1,63 @@ +import type { HermesGateway } from '@/hermes' +import type { ComposerAttachment } from '@/store/composer' + +import type { DroppedFile } from '../hooks/use-composer-actions' + +export interface ContextSuggestion { + text: string + display: string + meta?: string +} + +export interface QuickModelOption { + provider: string + providerName: string + model: string +} + +export interface ChatBarState { + model: { + model: string + provider: string + canSwitch: boolean + loading?: boolean + quickModels?: QuickModelOption[] + } + tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] } + voice: { enabled: boolean; active: boolean } +} + +export interface ChatBarProps { + busy: boolean + disabled: boolean + focusKey?: string | null + maxRecordingSeconds?: number + state: ChatBarState + gateway?: HermesGateway | null + queueSessionKey?: string | null + sessionId?: string | null + cwd?: string | null + onCancel: () => Promise<void> | void + onAddContextRef?: (refText: string, label?: string, detail?: string) => void + onAddUrl?: (url: string) => void + onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void + onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void + onPasteClipboardImage?: () => void + onPickFiles?: () => void + onPickFolders?: () => void + onPickImages?: () => void + onRemoveAttachment?: (id: string) => void + onSubmit: ( + value: string, + options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } + ) => Promise<boolean> | boolean + onTranscribeAudio?: (audio: Blob) => Promise<string> +} + +export type VoiceStatus = 'idle' | 'recording' | 'transcribing' + +export interface VoiceActivityState { + elapsedSeconds: number + level: number + status: VoiceStatus +} diff --git a/apps/desktop/src/app/chat/composer/url-dialog.tsx b/apps/desktop/src/app/chat/composer/url-dialog.tsx new file mode 100644 index 000000000..610f04ae3 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/url-dialog.tsx @@ -0,0 +1,86 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Globe } from '@/lib/icons' + +const URL_HINT = /^https?:\/\//i + +export function UrlDialog({ + inputRef, + onChange, + onOpenChange, + onSubmit, + open, + value +}: { + inputRef: React.RefObject<HTMLInputElement | null> + onChange: (value: string) => void + onOpenChange: (open: boolean) => void + onSubmit: () => void + open: boolean + value: string +}) { + const trimmed = value.trim() + const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed) + + return ( + <Dialog onOpenChange={onOpenChange} open={open}> + <DialogContent className="max-w-md gap-5"> + <DialogHeader className="flex-row items-center gap-3 sm:items-center"> + <span + aria-hidden + className="grid size-9 shrink-0 place-items-center rounded-xl bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15" + > + <Globe className="size-4" /> + </span> + <div className="grid gap-0.5 text-left"> + <DialogTitle>Attach a URL</DialogTitle> + <DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription> + </div> + </DialogHeader> + <form + className="grid gap-4" + onSubmit={e => { + e.preventDefault() + onSubmit() + }} + > + <div className="grid gap-1.5"> + <Input + autoComplete="off" + autoCorrect="off" + inputMode="url" + onChange={e => onChange(e.target.value)} + placeholder="https://example.com/post" + ref={inputRef} + spellCheck={false} + value={value} + /> + {trimmed.length > 0 && !looksLikeUrl && ( + <p className="text-xs text-muted-foreground/85"> + Include the full URL, e.g. <span className="font-mono">https://…</span> + </p> + )} + </div> + <DialogFooter> + <Button onClick={() => onOpenChange(false)} type="button" variant="ghost"> + Cancel + </Button> + <Button disabled={!looksLikeUrl} type="submit"> + Attach + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx new file mode 100644 index 000000000..b41e7aac8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -0,0 +1,248 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' + +import { Button } from '@/components/ui/button' +import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { stopVoicePlayback } from '@/lib/voice-playback' +import { $voicePlayback } from '@/store/voice-playback' + +import type { VoiceActivityState } from './types' + +type BrowserAudioContext = typeof AudioContext + +interface ElementAnalyser { + analyser: AnalyserNode +} + +const elementAnalysers = new WeakMap<HTMLAudioElement, ElementAnalyser>() +let playbackAudioContext: AudioContext | null = null + +function getPlaybackAudioContext(): AudioContext | null { + if (playbackAudioContext && playbackAudioContext.state !== 'closed') { + return playbackAudioContext + } + + const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext } + const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext + + if (!AudioContextCtor) { + return null + } + + playbackAudioContext = new AudioContextCtor() + + return playbackAudioContext +} + +function formatElapsed(seconds: number) { + const safeSeconds = Math.max(0, Math.floor(seconds)) + const minutes = Math.floor(safeSeconds / 60) + const remainingSeconds = safeSeconds % 60 + + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` +} + +function VoiceLevelBars({ level, active }: { active: boolean; level: number }) { + const normalized = Math.max(0, Math.min(level, 1)) + const bars = [0.5, 0.78, 1, 0.78, 0.5] + + return ( + <div aria-hidden="true" className="flex h-4 items-center gap-0.5"> + {bars.map((weight, index) => { + const height = active ? 0.25 + Math.min(0.68, normalized * weight) : 0.25 + + return ( + <span + className={cn( + 'w-0.5 rounded-full bg-current transition-[height,opacity] duration-100 ease-out', + active ? 'opacity-80' : 'animate-pulse opacity-45' + )} + key={index} + style={{ height: `${height * 100}%` }} + /> + ) + })} + </div> + ) +} + +function getElementAnalyser(audioElement: HTMLAudioElement): ElementAnalyser | null { + let entry = elementAnalysers.get(audioElement) + + if (!entry) { + const context = getPlaybackAudioContext() + + if (!context) { + return null + } + + const source = context.createMediaElementSource(audioElement) + const analyser = context.createAnalyser() + + analyser.fftSize = 512 + analyser.smoothingTimeConstant = 0.65 + source.connect(analyser) + analyser.connect(context.destination) + entry = { analyser } + elementAnalysers.set(audioElement, entry) + } + + void playbackAudioContext?.resume() + + return entry +} + +const WAVE_W = 88 +const WAVE_H = 16 +const BAR_W = 2 +const BAR_GAP = 5 +const STEP = BAR_W + BAR_GAP +const BARS = Math.floor((WAVE_W + BAR_GAP) / STEP) +const X0 = Math.round((WAVE_W - (BARS * STEP - BAR_GAP)) / 2) + +function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | null }) { + const canvasRef = useRef<HTMLCanvasElement | null>(null) + + useEffect(() => { + const canvas = canvasRef.current + + if (!canvas || !audioElement) { + return + } + + const entry = getElementAnalyser(audioElement) + const ctx = canvas.getContext('2d') + + if (!entry || !ctx) { + return + } + + const dpr = Math.max(1, window.devicePixelRatio || 1) + const { analyser } = entry + const buf = new Uint8Array(analyser.frequencyBinCount) + const hi = Math.floor(buf.length * 0.9) + + canvas.width = Math.round(WAVE_W * dpr) + canvas.height = Math.round(WAVE_H * dpr) + canvas.style.width = `${WAVE_W}px` + canvas.style.height = `${WAVE_H}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.imageSmoothingEnabled = false + ctx.fillStyle = getComputedStyle(canvas).color + + let raf = 0 + + const tick = () => { + analyser.getByteFrequencyData(buf) + ctx.clearRect(0, 0, WAVE_W, WAVE_H) + + for (let i = 0; i < BARS; i++) { + const a = Math.floor((i / BARS) * hi) + const b = Math.floor(((i + 1) / BARS) * hi) + let peak = 0 + + for (let j = a; j < b; j++) { + peak = Math.max(peak, buf[j] ?? 0) + } + + const amp = Math.sqrt(peak / 255) + const bh = Math.max(3, Math.round((0.18 + amp * 0.82) * WAVE_H)) + ctx.fillRect(X0 + i * STEP, Math.round((WAVE_H - bh) / 2), BAR_W, bh) + } + + raf = requestAnimationFrame(tick) + } + + tick() + + return () => cancelAnimationFrame(raf) + }, [audioElement]) + + return <canvas aria-hidden="true" className="block h-4 w-[88px]" ref={canvasRef} /> +} + +export function VoiceActivity({ state }: { state: VoiceActivityState }) { + if (state.status === 'idle') { + return null + } + + const recording = state.status === 'recording' + const title = recording ? 'Dictating' : 'Transcribing' + + return ( + <div + aria-live="polite" + className={cn( + 'flex h-8 items-center gap-2 rounded-xl border border-border/55 bg-muted/55 px-2.5 text-xs text-muted-foreground', + 'shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm' + )} + role="status" + > + <div + className={cn( + 'flex size-5 shrink-0 items-center justify-center rounded-full', + recording ? 'bg-primary/15 text-primary' : 'bg-primary/10 text-primary' + )} + > + {recording ? <Mic size={12} /> : <Loader2 className="animate-spin" size={12} />} + </div> + + <div className="flex min-w-0 flex-1 items-center gap-2"> + <span className="truncate font-medium text-foreground/85">{title}</span> + <span className="font-mono text-[0.6875rem] text-muted-foreground/85"> + {formatElapsed(state.elapsedSeconds)} + </span> + </div> + + <VoiceLevelBars active={recording} level={state.level} /> + </div> + ) +} + +export function VoicePlaybackActivity() { + const playback = useStore($voicePlayback) + + if (playback.status === 'idle') { + return null + } + + const preparing = playback.status === 'preparing' + + const title = preparing + ? 'Preparing audio' + : playback.source === 'voice-conversation' + ? 'Speaking response' + : 'Reading aloud' + + return ( + <div + aria-live="polite" + className={cn( + 'flex h-8 items-center gap-2 rounded-xl border border-primary/20 bg-primary/10 px-2.5 text-xs text-primary', + 'shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm' + )} + role="status" + > + <div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"> + {preparing ? <Loader2 className="animate-spin" size={12} /> : <Volume2 size={12} />} + </div> + + <div className="flex min-w-0 flex-1 items-center gap-2"> + <span className="truncate font-medium text-foreground/85">{title}</span> + {!preparing && <PlaybackWaveform audioElement={playback.audioElement} />} + </div> + + <Button + className="h-6 shrink-0 gap-1 rounded-full px-2 text-[0.6875rem]" + onClick={stopVoicePlayback} + size="sm" + type="button" + variant="ghost" + > + <VolumeX size={12} /> + Stop + </Button> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts new file mode 100644 index 000000000..e48ff7acc --- /dev/null +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -0,0 +1,522 @@ +import { useCallback } from 'react' + +import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus' +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' +import { + addComposerAttachment, + type ComposerAttachment, + removeComposerAttachment, + setComposerTerminalSelection +} from '@/store/composer' +import { notify, notifyError } from '@/store/notifications' + +import type { ImageDetachResponse } from '../../types' + +const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|ico)$/i + +const BLOB_MIME_EXTENSION: Record<string, string> = { + 'image/bmp': '.bmp', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/tiff': '.tiff', + 'image/webp': '.webp', + 'image/x-icon': '.ico' +} + +function blobExtension(blob: Blob): string { + const mime = blob.type.split(';')[0]?.trim().toLowerCase() + + return (mime && BLOB_MIME_EXTENSION[mime]) || '.png' +} + +function isImagePath(filePath: string): boolean { + return IMAGE_EXTENSION_PATTERN.test(filePath) +} + +export interface DroppedFile { + /** Browser-native File handle. Absent for in-app drags (e.g. project tree). */ + file?: File + /** Absolute filesystem path. Empty when an OS drop didn't carry one. */ + path: string + /** True if the entry is a directory. Currently only set by in-app drags. */ + isDirectory?: boolean + /** First line number for in-app line-ref drags (source view gutter). */ + line?: number + /** Last line number for line-range drags (`line..lineEnd` inclusive). */ + lineEnd?: number +} + +/** MIME emitted by in-app drag sources (project tree, gutter line numbers). + * Payload is JSON `{ path; isDirectory?; line?; lineEnd? }[]`. */ +export const HERMES_PATHS_MIME = 'application/x-hermes-paths' + +/** + * Eagerly resolve files from a drop event into [File?, path, isDirectory?] + * triples. Internal Hermes sources (e.g. the project tree) ride on a custom + * MIME and produce path-only entries; OS drops produce File-bearing entries. + * + * Must be called synchronously from inside the drop handler — `DataTransfer` + * items are detached as soon as the handler returns, and `webUtils.getPathForFile` + * also requires the original (non-cloned) File reference. + */ +export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { + const result: DroppedFile[] = [] + const seenPaths = new Set<string>() + const seenFiles = new Set<File>() + const getPath = window.hermesDesktop?.getPathForFile + + // In-app drags first — they carry richer metadata (isDirectory) than the + // File-based fallback can provide, and produce no overlapping native files. + try { + const internalRaw = transfer.getData(HERMES_PATHS_MIME) + + if (internalRaw) { + const parsed = JSON.parse(internalRaw) as { + path?: unknown + isDirectory?: unknown + line?: unknown + lineEnd?: unknown + }[] + + const positiveInt = (value: unknown) => (typeof value === 'number' && value > 0 ? Math.floor(value) : undefined) + + for (const entry of parsed) { + if (!entry || typeof entry.path !== 'string' || !entry.path) { + continue + } + + const line = positiveInt(entry.line) + const rawEnd = positiveInt(entry.lineEnd) + const lineEnd = line && rawEnd && rawEnd > line ? rawEnd : undefined + const dedupKey = line ? `${entry.path}:${line}-${lineEnd ?? line}` : entry.path + + if (seenPaths.has(dedupKey)) { + continue + } + + seenPaths.add(dedupKey) + result.push({ isDirectory: entry.isDirectory === true, line, lineEnd, path: entry.path }) + } + } + } catch { + // Malformed payload — fall through to native files. + } + + const fileList = transfer.files + + if (fileList) { + for (let i = 0; i < fileList.length; i += 1) { + const file = fileList.item(i) + + if (!file || seenFiles.has(file)) { + continue + } + + seenFiles.add(file) + let path = '' + + if (getPath) { + try { + path = getPath(file) || '' + } catch { + path = '' + } + } + + if (path && seenPaths.has(path)) { + continue + } + + if (path) { + seenPaths.add(path) + } + + result.push({ file, path }) + } + } + + const items = transfer.items + + if (items) { + for (let i = 0; i < items.length; i += 1) { + const item = items[i] + + if (!item || item.kind !== 'file') { + continue + } + + const file = item.getAsFile() + + if (!file || seenFiles.has(file)) { + continue + } + + seenFiles.add(file) + let path = '' + + if (getPath) { + try { + path = getPath(file) || '' + } catch { + path = '' + } + } + + if (path && seenPaths.has(path)) { + continue + } + + if (path) { + seenPaths.add(path) + } + + result.push({ file, path }) + } + } + + return result +} + +interface ComposerActionsOptions { + activeSessionId: string | null + currentCwd: string + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> +} + +/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */ +const attachToMain = (attachment: ComposerAttachment) => { + addComposerAttachment(attachment) + requestComposerFocus('main') +} + +export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) { + const addTextToDraft = useCallback((text: string) => { + requestComposerInsert(text, { mode: 'block' }) + }, []) + + const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => { + const trimmed = text.trim() + const normalizedLabel = label.trim() || 'selection' + const refText = `@terminal:${formatRefValue(normalizedLabel)}` + + if (!trimmed) { + return + } + + setComposerTerminalSelection(normalizedLabel, trimmed) + requestComposerInsert(refText, { mode: 'inline' }) + }, []) + + const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => { + const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:') + ? 'folder' + : refText.startsWith('@url:') + ? 'url' + : 'file' + + attachToMain({ + id: attachmentId(kind, refText), + kind, + label: label || refText.replace(/^@(file|folder|url):/, ''), + detail, + refText + }) + }, []) + + const pickContextPaths = useCallback( + async (kind: 'file' | 'folder') => { + const paths = await window.hermesDesktop?.selectPaths({ + title: kind === 'file' ? 'Add files as context' : 'Add folders as context', + defaultPath: currentCwd || undefined, + directories: kind === 'folder' + }) + + if (!paths?.length) { + return + } + + for (const path of paths) { + const rel = contextPath(path, currentCwd) + + attachToMain({ + id: attachmentId(kind, rel), + kind, + label: pathLabel(path), + detail: rel, + refText: `@${kind}:${formatRefValue(rel)}`, + path + }) + } + }, + [currentCwd] + ) + + const attachContextFilePath = useCallback( + (filePath: string) => { + if (!filePath) { + return false + } + + const rel = contextPath(filePath, currentCwd) + + attachToMain({ + id: attachmentId('file', rel), + kind: 'file', + label: pathLabel(filePath), + detail: rel, + refText: `@file:${formatRefValue(rel)}`, + path: filePath + }) + + return true + }, + [currentCwd] + ) + + const attachImagePath = useCallback(async (filePath: string) => { + if (!filePath) { + return false + } + + const baseAttachment: ComposerAttachment = { + id: attachmentId('image', filePath), + kind: 'image', + label: pathLabel(filePath), + detail: filePath, + path: filePath + } + + attachToMain(baseAttachment) + + try { + const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + + if (previewUrl) { + addComposerAttachment({ ...baseAttachment, previewUrl }) + } + + return true + } catch (err) { + notifyError(err, 'Image preview failed') + + return true + } + }, []) + + const attachImageBlob = useCallback( + async (blob: Blob) => { + if (blob.size === 0) { + return false + } + + if (blob.type && !blob.type.startsWith('image/')) { + return false + } + + try { + const buffer = await blob.arrayBuffer() + const data = new Uint8Array(buffer) + const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob)) + + if (!savedPath) { + notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' }) + + return false + } + + return attachImagePath(savedPath) + } catch (err) { + notifyError(err, 'Image attach failed') + + return false + } + }, + [attachImagePath] + ) + + const pickImages = useCallback(async () => { + const paths = await window.hermesDesktop?.selectPaths({ + title: 'Attach images', + defaultPath: currentCwd || undefined, + filters: [ + { + name: 'Images', + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff'] + } + ] + }) + + if (!paths?.length) { + return + } + + for (const path of paths) { + await attachImagePath(path) + } + }, [attachImagePath, currentCwd]) + + const pasteClipboardImage = useCallback(async () => { + try { + const path = await window.hermesDesktop?.saveClipboardImage() + + if (!path) { + notify({ + kind: 'warning', + title: 'Clipboard', + message: 'No image found in clipboard' + }) + + return + } + + await attachImagePath(path) + } catch (err) { + notifyError(err, 'Clipboard paste failed') + } + }, [attachImagePath]) + + const attachContextFolderPath = useCallback( + (folderPath: string) => { + if (!folderPath) { + return false + } + + const rel = contextPath(folderPath, currentCwd) + + attachToMain({ + id: attachmentId('folder', rel), + kind: 'folder', + label: pathLabel(folderPath), + detail: rel, + refText: `@folder:${formatRefValue(rel)}`, + path: folderPath + }) + + return true + }, + [currentCwd] + ) + + const attachDroppedItems = useCallback( + async (candidates: DroppedFile[]) => { + if (candidates.length === 0) { + return false + } + + let attached = false + let lastFailure: string | null = null + + for (const candidate of candidates) { + const { file, isDirectory, path: knownPath } = candidate + + // Path-only entry (in-app drag from the file browser tree, etc.). + if (!file) { + if (isDirectory) { + if (knownPath && attachContextFolderPath(knownPath)) { + attached = true + + continue + } + + lastFailure = `Could not attach folder ${knownPath || ''}` + + continue + } + + if (knownPath && isImagePath(knownPath)) { + if (await attachImagePath(knownPath)) { + attached = true + + continue + } + + lastFailure = `Could not attach ${knownPath}` + + continue + } + + if (knownPath && attachContextFilePath(knownPath)) { + attached = true + + continue + } + + lastFailure = `Could not attach ${knownPath || 'file'}` + + continue + } + + const fallbackPath = + !knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : '' + + const filePath = knownPath || fallbackPath || '' + const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath)) + + if (isImage) { + if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) { + attached = true + + continue + } + + lastFailure = `Could not attach ${file.name || 'image'}` + + continue + } + + if (filePath && attachContextFilePath(filePath)) { + attached = true + + continue + } + + lastFailure = `Could not attach ${file.name || 'file'}` + } + + if (!attached && lastFailure) { + notify({ kind: 'warning', title: 'Drop files', message: lastFailure }) + } + + return attached + }, + [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath] + ) + + const removeAttachment = useCallback( + async (id: string) => { + const removed = removeComposerAttachment(id) + + if ( + removed?.kind === 'image' && + removed.path && + activeSessionId && + removed.attachedSessionId && + removed.attachedSessionId === activeSessionId + ) { + await requestGateway<ImageDetachResponse>('image.detach', { + session_id: activeSessionId, + path: removed.path + }).catch(() => undefined) + } + }, + [activeSessionId, requestGateway] + ) + + return { + addContextRefAttachment, + addTerminalSelectionAttachment, + addTextToDraft, + attachContextFilePath, + attachContextFolderPath, + attachDroppedItems, + attachImageBlob, + attachImagePath, + pasteClipboardImage, + pickContextPaths, + pickImages, + removeAttachment + } +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx new file mode 100644 index 000000000..98cb2f636 --- /dev/null +++ b/apps/desktop/src/app/chat/index.tsx @@ -0,0 +1,332 @@ +import { + type AppendMessage, + AssistantRuntimeProvider, + ExportedMessageRepository, + type ThreadMessage +} from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import type * as React from 'react' +import { Suspense, useMemo, useRef } from 'react' +import { useLocation } from 'react-router-dom' + +import { Thread } from '@/components/assistant-ui/thread' +import { Backdrop } from '@/components/Backdrop' +import { NotificationStack } from '@/components/notifications' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { getGlobalModelOptions, type HermesGateway } from '@/hermes' +import type { ChatMessage } from '@/lib/chat-messages' +import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' +import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' +import { cn } from '@/lib/utils' +import type { ComposerAttachment } from '@/store/composer' +import { $pinnedSessionIds } from '@/store/layout' +import { + $activeSessionId, + $awaitingResponse, + $busy, + $contextSuggestions, + $currentCwd, + $currentModel, + $currentProvider, + $freshDraftReady, + $gatewayState, + $introPersonality, + $introSeed, + $messages, + $selectedStoredSessionId, + $sessions +} from '@/store/session' +import type { ModelOptionsResponse } from '@/types/hermes' + +import { routeSessionId } from '../routes' +import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar' + +import { ChatBar, ChatBarFallback } from './composer' +import type { ChatBarState } from './composer/types' +import type { DroppedFile } from './hooks/use-composer-actions' +import { SessionActionsMenu } from './sidebar/session-actions-menu' +import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' + +interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> { + gateway: HermesGateway | null + onToggleSelectedPin: () => void + onDeleteSelectedSession: () => void + onCancel: () => Promise<void> | void + onAddContextRef: (refText: string, label?: string, detail?: string) => void + onAddUrl: (url: string) => void + onBranchInNewChat: (messageId: string) => void + maxVoiceRecordingSeconds?: number + onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void + onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void + onPasteClipboardImage: () => void + onPickFiles: () => void + onPickFolders: () => void + onPickImages: () => void + onRemoveAttachment: (id: string) => void + onSubmit: ( + text: string, + options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } + ) => Promise<boolean> | boolean + onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void + onEdit: (message: AppendMessage) => Promise<void> + onReload: (parentId: string | null) => Promise<void> + onTranscribeAudio?: (audio: Blob) => Promise<string> +} + +interface ChatHeaderProps { + activeSessionId: null | string + isRoutedSessionView: boolean + onDeleteSelectedSession: () => void + onToggleSelectedPin: () => void + selectedSessionId: null | string +} + +function ChatHeader({ + activeSessionId, + isRoutedSessionView, + onDeleteSelectedSession, + onToggleSelectedPin, + selectedSessionId +}: ChatHeaderProps) { + const sessions = useStore($sessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null + const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent' + const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false + + return ( + <header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}> + <div className="min-w-0 flex-1"> + <SessionActionsMenu + align="start" + onDelete={selectedSessionId ? onDeleteSelectedSession : undefined} + onPin={selectedSessionId ? onToggleSelectedPin : undefined} + pinned={selectedIsPinned} + sessionId={selectedSessionId || activeSessionId || ''} + sideOffset={8} + title={title} + > + <Button + className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]" + type="button" + variant="ghost" + > + <h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2> + <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" /> + </Button> + </SessionActionsMenu> + </div> + </header> + ) +} + +export function ChatView({ + className, + gateway, + onToggleSelectedPin, + onDeleteSelectedSession, + onCancel, + onAddContextRef, + onAddUrl, + onAttachImageBlob, + onAttachDroppedItems, + onBranchInNewChat, + maxVoiceRecordingSeconds, + onPasteClipboardImage, + onPickFiles, + onPickFolders, + onPickImages, + onRemoveAttachment, + onSubmit, + onThreadMessagesChange, + onEdit, + onReload, + onTranscribeAudio +}: ChatViewProps) { + const location = useLocation() + const activeSessionId = useStore($activeSessionId) + const awaitingResponse = useStore($awaitingResponse) + const busy = useStore($busy) + const contextSuggestions = useStore($contextSuggestions) + const currentCwd = useStore($currentCwd) + const currentModel = useStore($currentModel) + const currentProvider = useStore($currentProvider) + const freshDraftReady = useStore($freshDraftReady) + const gatewayState = useStore($gatewayState) + const gatewayOpen = gatewayState === 'open' + const introPersonality = useStore($introPersonality) + const introSeed = useStore($introSeed) + const messages = useStore($messages) + const selectedSessionId = useStore($selectedStoredSessionId) + const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>()) + const isRoutedSessionView = Boolean(routeSessionId(location.pathname)) + + const showIntro = + freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0 + + // Session is still loading if the route references a session we haven't + // resumed yet. Once `activeSessionId` is set (runtime has resumed), the + // session exists — even if it has zero messages (a brand-new routed + // session). The flicker where `busy` flips true briefly during hydrate + // is handled by `threadLoadingState`'s last-visible-user gate. + const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId + const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages)) + const showChatBar = !loadingSession + const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') + + const modelOptionsQuery = useQuery<ModelOptionsResponse>({ + queryKey: ['model-options', activeSessionId || 'global'], + queryFn: () => { + if (!activeSessionId) { + return getGlobalModelOptions() + } + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId }) + }, + enabled: gatewayOpen + }) + + const quickModels = useMemo( + () => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel), + [currentModel, currentProvider, modelOptionsQuery.data] + ) + + const chatBarState = useMemo<ChatBarState>( + () => ({ + model: { + model: currentModel, + provider: currentProvider, + canSwitch: gatewayOpen, + loading: !gatewayOpen || (!currentModel && !currentProvider), + quickModels + }, + tools: { + enabled: true, + label: 'Add context', + suggestions: contextSuggestions + }, + voice: { + enabled: true, + active: false + } + }), + [contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] + ) + + const runtimeMessageRepository = useMemo(() => { + const items: { message: ThreadMessage; parentId: string | null }[] = [] + const branchParentByGroup = new Map<string, string | null>() + let visibleParentId: string | null = null + let headId: string | null = null + + for (const message of messages) { + let parentId = visibleParentId + + if (message.role === 'assistant' && message.branchGroupId) { + if (!branchParentByGroup.has(message.branchGroupId)) { + branchParentByGroup.set(message.branchGroupId, visibleParentId) + } + + parentId = branchParentByGroup.get(message.branchGroupId) ?? null + } + + const cachedMessage = runtimeMessageCacheRef.current.get(message) + const runtimeMessage = cachedMessage ?? toRuntimeMessage(message) + + if (!cachedMessage) { + runtimeMessageCacheRef.current.set(message, runtimeMessage) + } + + items.push({ message: runtimeMessage, parentId }) + + if (!message.hidden) { + visibleParentId = message.id + headId = message.id + } + } + + return ExportedMessageRepository.fromBranchableArray(items, { headId }) + }, [messages]) + + const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({ + messageRepository: runtimeMessageRepository, + isRunning: busy, + setMessages: onThreadMessagesChange, + onNew: async () => { + // Submission is handled explicitly by ChatBar. + // Keeping this no-op avoids duplicate prompt.submit calls. + }, + onEdit, + onCancel: async () => onCancel(), + onReload + }) + + return ( + <div + className={cn( + 'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', + className + )} + > + <Backdrop /> + <ChatHeader + activeSessionId={activeSessionId} + isRoutedSessionView={isRoutedSessionView} + onDeleteSelectedSession={onDeleteSelectedSession} + onToggleSelectedPin={onToggleSelectedPin} + selectedSessionId={selectedSessionId} + /> + + <NotificationStack /> + + <div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"> + <AssistantRuntimeProvider runtime={runtime}> + <Thread + clampToComposer={showChatBar} + cwd={currentCwd} + gateway={gateway} + intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined} + loading={threadLoading} + onBranchInNewChat={onBranchInNewChat} + onCancel={onCancel} + sessionId={activeSessionId} + sessionKey={threadKey} + /> + {showChatBar && ( + <Suspense fallback={<ChatBarFallback />}> + <ChatBar + busy={busy} + cwd={currentCwd} + disabled={!gatewayOpen} + focusKey={activeSessionId} + gateway={gateway} + maxRecordingSeconds={maxVoiceRecordingSeconds} + onAddContextRef={onAddContextRef} + onAddUrl={onAddUrl} + onAttachDroppedItems={onAttachDroppedItems} + onAttachImageBlob={onAttachImageBlob} + onCancel={onCancel} + onPasteClipboardImage={onPasteClipboardImage} + onPickFiles={onPickFiles} + onPickFolders={onPickFolders} + onPickImages={onPickImages} + onRemoveAttachment={onRemoveAttachment} + onSubmit={onSubmit} + onTranscribeAudio={onTranscribeAudio} + queueSessionKey={selectedSessionId || activeSessionId} + sessionId={activeSessionId} + state={chatBarState} + /> + </Suspense> + )} + </AssistantRuntimeProvider> + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/perf-probe.tsx b/apps/desktop/src/app/chat/perf-probe.tsx new file mode 100644 index 000000000..f128c9cb3 --- /dev/null +++ b/apps/desktop/src/app/chat/perf-probe.tsx @@ -0,0 +1,221 @@ +import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react' + +import { $messages, setMessages, setBusy } from '@/store/session' + +type Sample = { + id: string + phase: string + actualDuration: number + baseDuration: number + startTime: number + commitTime: number +} + +type SyntheticDriverHandle = { stop: () => void } + +declare global { + interface Window { + __PERF_PROBE__?: { + samples: Sample[] + enabled: boolean + clear: () => void + summary: () => Record<string, { count: number; total: number; max: number; p50: number; p95: number }> + } + __PERF_DRIVE__?: { + /** Inject an assistant message and grow it by `chunk` every `intervalMs`. Returns a stop handle. */ + stream: (opts?: { chunk?: string; intervalMs?: number; totalTokens?: number }) => SyntheticDriverHandle + reset: () => void + snapshotMsgs: () => number + } + } +} + +if (typeof window !== 'undefined' && !window.__PERF_PROBE__) { + const samples: Sample[] = [] + window.__PERF_PROBE__ = { + samples, + enabled: false, + clear: () => { + samples.length = 0 + }, + summary: () => { + const byId = new Map<string, number[]>() + for (const s of samples) { + const k = `${s.id}:${s.phase}` + const arr = byId.get(k) ?? [] + arr.push(s.actualDuration) + byId.set(k, arr) + } + const out: Record<string, { count: number; total: number; max: number; p50: number; p95: number }> = {} + for (const [k, arr] of byId) { + arr.sort((a, b) => a - b) + const total = arr.reduce((a, b) => a + b, 0) + out[k] = { + count: arr.length, + total: Math.round(total * 100) / 100, + max: Math.round(arr[arr.length - 1] * 100) / 100, + p50: Math.round(arr[Math.floor(arr.length * 0.5)] * 100) / 100, + p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100, + } + } + return out + }, + } +} + +const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => { + const probe = typeof window !== 'undefined' ? window.__PERF_PROBE__ : undefined + if (!probe || !probe.enabled) return + probe.samples.push({ id, phase, actualDuration, baseDuration, startTime, commitTime }) + if (probe.samples.length > 5000) probe.samples.splice(0, probe.samples.length - 5000) +} + +if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) { + // Synthetic stream driver — pushes tokens through the live $messages atom so the + // assistant-ui runtime + react tree sees them exactly as a real LLM stream would. + // Used by scripts/measure-real-stream.mjs when no live LLM credit is available. + let baseline: ReturnType<typeof $messages.get> | null = null + let activeHandle: SyntheticDriverHandle | null = null + + const stop = () => { + activeHandle = null + setBusy(false) + } + + window.__PERF_DRIVE__ = { + snapshotMsgs: () => $messages.get().length, + reset: () => { + activeHandle?.stop() + if (baseline) setMessages(baseline) + baseline = null + setBusy(false) + }, + stream: ({ + chunk = 'word ', + intervalMs = 16, + totalTokens = 400, + // Mimic `use-message-stream.scheduleDeltaFlush` — batch token deltas + // into at-most one $messages update every `flushMinMs` ms, exactly as + // the real gateway path does. With this on, the synthetic harness's + // numbers actually reflect what a real LLM stream of the same token + // rate would feel like. Set to 0 to bypass and apply every token + // immediately (worst-case). + flushMinMs = 0 + }: { chunk?: string; intervalMs?: number; totalTokens?: number; flushMinMs?: number } = {}) => { + activeHandle?.stop() + const current = $messages.get() + if (!baseline) baseline = current + const msgId = `synthetic-${Date.now()}` + // Seed an empty assistant message — assistant-ui will see it grow. + setMessages([ + ...current, + { + id: msgId, + role: 'assistant', + parts: [{ type: 'text', text: '' }], + timestamp: Date.now(), + pending: true + } + ]) + setBusy(true) + + let pushed = 0 + let pendingDelta = '' + let lastFlushAt = 0 + let timer: ReturnType<typeof setTimeout> | null = null + let flushHandle: number | null = null + + const applyDelta = (delta: string) => { + if (!delta) return + setMessages(prev => + prev.map(m => { + if (m.id !== msgId) return m + const head = m.parts.slice(0, -1) + const last = m.parts.at(-1) + const lastText = last && last.type === 'text' ? last.text : '' + return { + ...m, + parts: [...head, { type: 'text', text: lastText + delta }] + } + }) + ) + } + + const flushNow = () => { + flushHandle = null + lastFlushAt = performance.now() + const delta = pendingDelta + pendingDelta = '' + applyDelta(delta) + } + + const scheduleFlush = () => { + if (flushHandle !== null) return + if (flushMinMs <= 0) { flushNow(); return } + const since = performance.now() - lastFlushAt + const wait = Math.max(0, flushMinMs - since) + flushHandle = + wait <= 0 && typeof requestAnimationFrame === 'function' + ? requestAnimationFrame(flushNow) + : (setTimeout(flushNow, wait) as unknown as number) + } + + const handle: SyntheticDriverHandle = { + stop: () => { + if (timer) clearTimeout(timer) + timer = null + if (flushHandle !== null) { + clearTimeout(flushHandle) + cancelAnimationFrame?.(flushHandle) + } + flushHandle = null + if (pendingDelta) { + applyDelta(pendingDelta) + pendingDelta = '' + } + activeHandle = null + // Mark message finalized. + setMessages(prev => + prev.map(m => + m.id === msgId + ? { ...m, pending: false } + : m + ) + ) + setBusy(false) + } + } + activeHandle = handle + + const tick = () => { + if (activeHandle !== handle) return + if (pushed >= totalTokens) { + if (pendingDelta) flushNow() + handle.stop() + return + } + pushed += 1 + if (flushMinMs > 0) { + pendingDelta += chunk + scheduleFlush() + } else { + applyDelta(chunk) + } + timer = setTimeout(tick, intervalMs) + } + timer = setTimeout(tick, intervalMs) + return handle + } + } + + // Suppress dead-import warning. + void stop +} + +export function PerfProbe({ id, children }: { id: string; children: ReactNode }) { + return ( + <Profiler id={id} onRender={onRender}> + {children} + </Profiler> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/index.ts b/apps/desktop/src/app/chat/right-rail/index.ts new file mode 100644 index 000000000..8bb73a68a --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/index.ts @@ -0,0 +1 @@ +export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview' diff --git a/apps/desktop/src/app/chat/right-rail/preview-console-state.ts b/apps/desktop/src/app/chat/right-rail/preview-console-state.ts new file mode 100644 index 000000000..057742d7b --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-console-state.ts @@ -0,0 +1,82 @@ +import { atom, computed } from 'nanostores' + +type Updater<T> = T | ((current: T) => T) + +interface WritableStore<T> { + get: () => T + set: (value: T) => void +} + +const DEFAULT_CONSOLE_HEIGHT = 240 + +export interface ConsoleEntry { + id: number + level: number + line?: number + message: string + source?: string +} + +export interface ConsoleEntryInput { + level: number + line?: number + message: string + source?: string +} + +function updateAtom<T>(store: WritableStore<T>, next: Updater<T>) { + store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next) +} + +export function createPreviewConsoleState() { + const $height = atom(DEFAULT_CONSOLE_HEIGHT) + const $logs = atom<ConsoleEntry[]>([]) + const $logCount = computed($logs, logs => logs.length) + const $open = atom(false) + const $selectedLogIds = atom<ReadonlySet<number>>(new Set()) + let nextLogId = 0 + + return { + $height, + $logCount, + $logs, + $open, + $selectedLogIds, + append(entry: ConsoleEntryInput) { + $logs.set([...$logs.get().slice(-199), { ...entry, id: ++nextLogId }]) + }, + clear() { + $logs.set([]) + $selectedLogIds.set(new Set()) + }, + clearSelection() { + if ($selectedLogIds.get().size === 0) { + return + } + + $selectedLogIds.set(new Set()) + }, + reset() { + nextLogId = 0 + $logs.set([]) + $selectedLogIds.set(new Set()) + }, + setHeight(next: Updater<number>) { + updateAtom($height, next) + }, + setOpen(next: Updater<boolean>) { + updateAtom($open, next) + }, + toggleSelection(id: number) { + const next = new Set($selectedLogIds.get()) + + if (!next.delete(id)) { + next.add(id) + } + + $selectedLogIds.set(next) + } + } +} + +export type PreviewConsoleState = ReturnType<typeof createPreviewConsoleState> diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx new file mode 100644 index 000000000..3617f59a1 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx @@ -0,0 +1,286 @@ +import { useStore } from '@nanostores/react' +import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react' +import { useEffect, useMemo, useRef } from 'react' + +import { requestComposerInsert } from '@/app/chat/composer/focus' +import { CopyButton } from '@/components/ui/copy-button' +import { PanelBottom, Send, Trash2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify } from '@/store/notifications' + +import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state' + +const consoleLevelLabel: Record<number, string> = { + 0: 'log', + 1: 'info', + 2: 'warn', + 3: 'error' +} + +const consoleLevelClass: Record<number, string> = { + 0: 'text-foreground', + 1: 'text-sky-700 dark:text-sky-300', + 2: 'text-amber-700 dark:text-amber-300', + 3: 'text-destructive' +} + +const CONSOLE_BOTTOM_THRESHOLD = 24 +const CONSOLE_HEADER_HEIGHT = 32 + +export function compactUrl(value: string): string { + try { + const url = new URL(value) + + if (url.protocol === 'file:') { + return decodeURIComponent(url.pathname) + } + + return `${url.host}${url.pathname}${url.search}` + } catch { + return value + } +} + +export function formatLogLine(log: ConsoleEntry): string { + const head = `[${consoleLevelLabel[log.level] || 'log'}]` + const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : '' + + return `${head} ${log.message}${tail}`.trim() +} + +export function formatConsoleEntries(entries: ConsoleEntry[]): string { + return entries.map(formatLogLine).join('\n') +} + +export function isNearConsoleBottom(element: HTMLDivElement | null): boolean { + if (!element) { + return true + } + + return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD +} + +export function clampConsoleHeight(value: number): number { + return Math.max(value, CONSOLE_HEADER_HEIGHT) +} + +interface ConsoleRowProps { + copyText: string + log: ConsoleEntry + onSend: () => void + onToggleSelect: () => void + selected: boolean +} + +function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) { + return ( + <div + className={cn( + 'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40', + selected && 'border-border/60 bg-accent/40' + )} + > + <button + className={cn( + 'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100', + consoleLevelClass[log.level] ?? consoleLevelClass[0] + )} + onClick={onToggleSelect} + title={selected ? 'Deselect entry' : 'Select entry'} + type="button" + > + {consoleLevelLabel[log.level] || 'log'} + </button> + <div className="min-w-0" data-selectable-text="true"> + <span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}> + {log.message} + </span> + {log.source && ( + <span className="block truncate text-muted-foreground/60"> + {compactUrl(log.source)} + {log.line ? `:${log.line}` : ''} + </span> + )} + </div> + <span className="opacity-0 transition-opacity group-hover/row:opacity-100"> + <CopyButton + appearance="inline" + className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + errorMessage="Could not copy console output" + iconClassName="size-3" + label="Copy this entry" + showLabel={false} + text={copyText} + /> + <button + className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + onClick={onSend} + title="Send this entry to chat" + type="button" + > + <Send className="size-3" /> + </button> + </span> + </div> + ) +} + +export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) { + const logCount = useStore(consoleState.$logCount) + + return ( + <> + <PanelBottom /> + {logCount > 0 && <span className="sr-only">{logCount} console messages</span>} + </> + ) +} + +interface PreviewConsolePanelProps { + consoleBodyRef: RefObject<HTMLDivElement | null> + consoleShouldStickRef: MutableRefObject<boolean> + consoleState: PreviewConsoleState + startConsoleResize: (event: ReactPointerEvent<HTMLDivElement>) => void +} + +export function PreviewConsolePanel({ + consoleBodyRef, + consoleShouldStickRef, + consoleState, + startConsoleResize +}: PreviewConsolePanelProps) { + const consoleHeight = useStore(consoleState.$height) + const logs = useStore(consoleState.$logs) + const selectedLogIds = useStore(consoleState.$selectedLogIds) + const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds]) + const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs + const stickScrollRafRef = useRef<number | null>(null) + + useEffect(() => { + if (!consoleShouldStickRef.current) { + return + } + + if (stickScrollRafRef.current !== null) { + window.cancelAnimationFrame(stickScrollRafRef.current) + stickScrollRafRef.current = null + } + + stickScrollRafRef.current = window.requestAnimationFrame(() => { + stickScrollRafRef.current = null + const consoleBody = consoleBodyRef.current + consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + }) + + return () => { + if (stickScrollRafRef.current !== null) { + window.cancelAnimationFrame(stickScrollRafRef.current) + stickScrollRafRef.current = null + } + } + }, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs]) + + function sendLogsToComposer(entries: ConsoleEntry[]) { + if (!entries.length) { + return + } + + const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n') + + requestComposerInsert(block, { mode: 'block', target: 'main' }) + consoleState.clearSelection() + notify({ + kind: 'success', + title: 'Sent to chat', + message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer` + }) + } + + return ( + <div + className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background" + style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties} + > + <div + aria-label="Resize preview console" + className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize" + onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)} + onPointerDown={startConsoleResize} + role="separator" + > + <span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" /> + </div> + <div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2"> + <div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground"> + <PanelBottom className="size-3.5" /> + Preview Console + {selectedLogIds.size > 0 && ( + <span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground"> + {selectedLogIds.size} selected + </span> + )} + </div> + <div className="flex items-center gap-1"> + <button + className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" + disabled={sendableLogs.length === 0} + onClick={() => sendLogsToComposer(sendableLogs)} + title={ + visibleSelection.length > 0 + ? `Send ${visibleSelection.length} selected to chat` + : 'Send all log entries to chat' + } + type="button" + > + <Send className="size-3" /> + Send to chat + </button> + <CopyButton + appearance="inline" + className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" + disabled={sendableLogs.length === 0} + errorMessage="Could not copy console output" + iconClassName="size-3" + label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'} + text={() => formatConsoleEntries(sendableLogs)} + > + Copy + </CopyButton> + <button + className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" + disabled={logs.length === 0} + onClick={consoleState.clear} + title="Clear console" + type="button" + > + <Trash2 className="size-3" /> + Clear + </button> + </div> + </div> + <div + className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" + ref={consoleBodyRef} + > + {logs.length > 0 ? ( + logs.map(log => { + const selected = selectedLogIds.has(log.id) + + return ( + <ConsoleRow + copyText={formatLogLine(log)} + key={log.id} + log={log} + onSend={() => sendLogsToComposer([log])} + onToggleSelect={() => consoleState.toggleSelection(log.id)} + selected={selected} + /> + ) + }) + ) : ( + <div className="py-2 text-muted-foreground/70">No console messages yet.</div> + )} + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx new file mode 100644 index 000000000..708961c23 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -0,0 +1,553 @@ +import type * as React from 'react' +import type { + ComponentProps, + CSSProperties, + DragEvent as ReactDragEvent, + MouseEvent as ReactMouseEvent, + ReactNode +} from 'react' +import { useEffect, useMemo, useState } from 'react' +import ShikiHighlighter from 'react-shiki' +import { Streamdown } from 'streamdown' + +import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' +import { cn } from '@/lib/utils' +import type { PreviewTarget } from '@/store/preview' + +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 + +type EmptyStateTone = 'neutral' | 'warning' + +const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = { + neutral: { + cube: 'text-muted-foreground/35', + primary: 'border-border bg-background text-foreground hover:bg-accent' + }, + warning: { + cube: 'text-amber-500/70 dark:text-amber-300/70', + primary: + 'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20' + } +} + +function PreviewCubeIcon({ className }: { className?: string }) { + return ( + <svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64"> + <path + d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z" + fill="none" + stroke="currentColor" + strokeLinejoin="round" + strokeWidth="1.25" + /> + <path + d="M8 18.5 32 32l24-13.5M32 32v27" + fill="none" + stroke="currentColor" + strokeLinejoin="round" + strokeWidth="1.25" + /> + <path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" /> + </svg> + ) +} + +interface PreviewEmptyStateProps { + body?: ReactNode + consoleHeight?: number + primaryAction?: { disabled?: boolean; label: string; onClick: () => void } + secondaryAction?: { disabled?: boolean; label: string; onClick: () => void } + title: string + tone?: EmptyStateTone +} + +export function PreviewEmptyState({ + body, + consoleHeight = 0, + primaryAction, + secondaryAction, + title, + tone = 'neutral' +}: PreviewEmptyStateProps) { + const styles = TONE_STYLES[tone] + + return ( + <div + className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)" + style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties} + > + <div className="grid max-w-sm justify-items-center gap-5"> + <PreviewCubeIcon className={styles.cube} /> + <div className="grid gap-2"> + <div className="text-sm font-medium text-foreground">{title}</div> + {body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>} + </div> + {(primaryAction || secondaryAction) && ( + <div className="grid justify-items-center gap-2"> + {primaryAction && ( + <button + className={cn( + 'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60', + styles.primary + )} + disabled={primaryAction.disabled} + onClick={primaryAction.onClick} + type="button" + > + {primaryAction.label} + </button> + )} + {secondaryAction && ( + <button + className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline" + disabled={secondaryAction.disabled} + onClick={secondaryAction.onClick} + type="button" + > + {secondaryAction.label} + </button> + )} + </div> + )} + </div> + </div> + ) +} + +interface LocalPreviewState { + binary?: boolean + byteSize?: number + dataUrl?: string + error?: string + language?: string + loading: boolean + text?: string + truncated?: boolean +} + +function filePathForTarget(target: PreviewTarget) { + if (target.path) { + return target.path + } + + try { + const url = new URL(target.url) + + return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url + } catch { + return target.url + } +} + +function formatBytes(bytes: number | undefined) { + if (!bytes) { + return 'unknown size' + } + + const units = ['B', 'KB', 'MB', 'GB'] + let value = bytes + let unit = 0 + + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + + return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` +} + +function looksBinaryBytes(bytes: Uint8Array) { + if (!bytes.length) { + return false + } + + let suspicious = 0 + + for (const byte of bytes.slice(0, 4096)) { + if (byte === 0) { + return true + } + + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { + suspicious += 1 + } + } + + return suspicious / Math.min(bytes.length, 4096) > 0.12 +} + +async function readTextPreview(filePath: string) { + if (window.hermesDesktop.readFileText) { + try { + return await window.hermesDesktop.readFileText(filePath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + + if (!message.includes("No handler registered for 'hermes:readFileText'")) { + throw error + } + } + } + + // Back-compat for a running Electron process whose preload hasn't been + // restarted since readFileText was added. readFileDataUrl already existed. + const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath) + const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || [] + const base64 = metadata.includes(';base64') + const mimeType = metadata.replace(/;base64$/, '') || undefined + const raw = base64 ? atob(data) : decodeURIComponent(data) + const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0)) + + return { + binary: looksBinaryBytes(bytes), + byteSize: bytes.byteLength, + mimeType, + path: filePath, + text: new TextDecoder().decode(bytes) + } +} + +// Lightweight markdown renderer for file previews. Streamdown does the parse; +// our components keep typography simple and route fenced code through Shiki +// without the library's copy/download/fullscreen chrome. +const MD_TAG_CLASSES = { + h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0', + h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0', + h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0', + h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0', + p: 'mb-4 leading-relaxed text-foreground last:mb-0', + ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0', + ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0', + li: 'mt-1 leading-relaxed', + blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0', + pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono' +} as const + +function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) { + const base = MD_TAG_CLASSES[Tag] + + const Component = (({ className, ...rest }: ComponentProps<T>) => { + const Element = Tag as React.ElementType + + return <Element className={cn(base, className)} {...rest} /> + }) as React.FC<ComponentProps<T>> + + Component.displayName = `Md.${Tag}` + + return Component +} + +function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) { + const language = /language-([^\s]+)/.exec(className || '')?.[1] + + if (!language) { + return ( + <code + className={cn( + 'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300', + className + )} + {...props} + > + {children} + </code> + ) + } + + return ( + <ShikiHighlighter + addDefaultStyles={false} + as="div" + defaultColor="light-dark()" + delay={80} + language={language} + showLanguage={false} + theme={SHIKI_THEME} + > + {String(children).replace(/\n$/, '')} + </ShikiHighlighter> + ) +} + +const MARKDOWN_COMPONENTS = { + h1: tagged('h1'), + h2: tagged('h2'), + h3: tagged('h3'), + h4: tagged('h4'), + p: tagged('p'), + ul: tagged('ul'), + ol: tagged('ol'), + li: tagged('li'), + blockquote: tagged('blockquote'), + pre: tagged('pre'), + code: MarkdownCode +} + +function MarkdownPreview({ text }: { text: string }) { + return ( + <div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground"> + <Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}> + {text} + </Streamdown> + </div> + ) +} + +function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) { + return ( + <div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur"> + <button + className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground" + onClick={onToggle} + type="button" + > + {asSource ? 'PREVIEW' : 'SOURCE'} + </button> + </div> + ) +} + +// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so +// each line aligns vertically. The selection overlay relies on the same +// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself. +const SOURCE_LINE_HEIGHT_REM = 1.21875 +const SOURCE_PAD_Y_REM = 0.75 + +interface LineSelection { + end: number + start: number +} + +function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) { + const lineEnd = end > start ? end : undefined + const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}` + + event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }])) + event.dataTransfer.setData('text/plain', label) + event.dataTransfer.effectAllowed = 'copy' +} + +function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) { + const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text]) + const [selection, setSelection] = useState<LineSelection | null>(null) + const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end + + const handleLineClick = (event: ReactMouseEvent, line: number) => { + if (event.shiftKey && selection) { + setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) }) + + return + } + + if (selection?.start === line && selection.end === line) { + setSelection(null) + + return + } + + setSelection({ end: line, start: line }) + } + + const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => { + startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line }) + } + + return ( + <div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed"> + <div className="select-none py-3 text-right text-muted-foreground/55"> + {Array.from({ length: lineCount }, (_, index) => { + const line = index + 1 + const selected = inSelection(line) + + return ( + <div + className={cn( + 'cursor-pointer px-3 tabular-nums transition-colors', + selected + ? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100' + : 'hover:text-foreground' + )} + draggable + key={line} + onClick={event => handleLineClick(event, line)} + onDragStart={event => handleDragStart(event, line)} + title="Click to select · shift-click to extend · drag to composer" + > + {line} + </div> + ) + })} + </div> + <div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"> + {selection && ( + <div + aria-hidden + className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10" + style={{ + top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`, + height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)` + }} + /> + )} + <ShikiHighlighter + addDefaultStyles={false} + as="div" + defaultColor="light-dark()" + delay={80} + language={language || 'text'} + showLanguage={false} + theme={SHIKI_THEME} + > + {text} + </ShikiHighlighter> + </div> + </div> + ) +} + +export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) { + const [state, setState] = useState<LocalPreviewState>({ loading: true }) + const [forcePreview, setForcePreview] = useState(false) + const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false) + const filePath = filePathForTarget(target) + const isImage = target.previewKind === 'image' + + // HTML files are rendered as source code, not in a webview - so they take + // the same path as plain text files. `previewKind === 'binary'` arrives + // when the file is forcibly previewed past the binary refusal screen. + const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html' + + const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large) + + useEffect(() => { + let active = true + + async function load() { + if (blockedByTarget) { + setState({ loading: false }) + + return + } + + if (!isImage && !isText) { + setState({ loading: false }) + + return + } + + setState({ loading: true }) + + try { + if (isImage) { + const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath) + + if (active) { + setState({ dataUrl, loading: false }) + } + + return + } + + const result = await readTextPreview(filePath) + + if (active) { + const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES) + + setState({ + binary: result.binary, + byteSize: result.byteSize, + language: result.language || target.language || 'text', + loading: false, + text: shouldBlock ? undefined : result.text, + truncated: result.truncated + }) + } + } catch (error) { + if (active) { + setState({ + error: error instanceof Error ? error.message : String(error), + loading: false + }) + } + } + } + + void load() + + return () => { + active = false + } + }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language]) + + if (state.loading) { + return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview…</div> + } + + if (state.error) { + return <PreviewEmptyState body={state.error} title="Preview unavailable" /> + } + + if ( + !isImage && + !forcePreview && + (target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES) + ) { + const binary = target.binary || state.binary + const size = target.byteSize || state.byteSize + + return ( + <PreviewEmptyState + body={ + binary + ? `Previewing ${target.label} may show unreadable text.` + : `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.` + } + primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }} + title={binary ? 'This looks like a binary file' : 'This file is large'} + tone="warning" + /> + ) + } + + if (isImage && state.dataUrl) { + return ( + <div className="flex h-full w-full items-center justify-center overflow-auto bg-transparent p-4"> + <img + alt={target.label} + className="max-h-full max-w-full rounded-lg object-contain shadow-sm" + draggable={false} + src={state.dataUrl} + /> + </div> + ) + } + + if (isText && state.text !== undefined) { + const isMarkdown = (state.language || target.language) === 'markdown' + const showRendered = isMarkdown && !renderMarkdownAsSource + + return ( + <div className="h-full overflow-auto bg-transparent"> + {state.truncated && ( + <div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground"> + Showing first 512 KB. + </div> + )} + {isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />} + {showRendered ? ( + <MarkdownPreview text={state.text} /> + ) : ( + <SourceView filePath={filePath} language={state.language || 'text'} text={state.text} /> + )} + </div> + ) + } + + return ( + <PreviewEmptyState + body={`${target.mimeType || 'This file type'} can still be attached as context.`} + title="No inline preview" + /> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx new file mode 100644 index 000000000..163511b05 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -0,0 +1,43 @@ +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { PreviewPane } from './preview-pane' + +describe('PreviewPane console state', () => { + afterEach(() => { + cleanup() + }) + + it('does not rebuild the pane titlebar group for streamed console logs', () => { + const setTitlebarToolGroup = vi.fn() + + const rendered = render( + <PreviewPane + setTitlebarToolGroup={setTitlebarToolGroup} + target={{ + kind: 'url', + label: 'Preview', + source: 'http://localhost:5174', + url: 'http://localhost:5174' + }} + /> + ) + + const initialCalls = setTitlebarToolGroup.mock.calls.length + const webview = rendered.container.querySelector('webview') + + expect(webview).toBeInstanceOf(HTMLElement) + + act(() => { + webview?.dispatchEvent( + Object.assign(new Event('console-message'), { + level: 0, + message: 'streamed log line', + sourceId: 'http://localhost:5174/src/main.tsx' + }) + ) + }) + + expect(setTitlebarToolGroup).toHaveBeenCalledTimes(initialCalls) + }) +}) diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx new file mode 100644 index 000000000..7cd405aa9 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -0,0 +1,657 @@ +import { useStore } from '@nanostores/react' +import type { PointerEvent as ReactPointerEvent } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' +import { Bug } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview' + +import { + clampConsoleHeight, + compactUrl, + formatLogLine, + isNearConsoleBottom, + PreviewConsolePanel, + PreviewConsoleTitlebarIcon +} from './preview-console' +import { type ConsoleEntry, createPreviewConsoleState } from './preview-console-state' +import { LocalFilePreview, PreviewEmptyState } from './preview-file' + +type PreviewWebview = HTMLElement & { + closeDevTools?: () => void + getURL?: () => string + isDevToolsOpened?: () => boolean + openDevTools?: () => void + reload?: () => void + reloadIgnoringCache?: () => void +} + +interface PreviewPaneProps { + embedded?: boolean + onRestartServer?: (url: string, context?: string) => Promise<string> + reloadRequest?: number + setTitlebarToolGroup?: SetTitlebarToolGroup + target: PreviewTarget +} + +interface PreviewLoadErrorState { + code?: number + description: string + url: string +} + +const FILE_RELOAD_DEBOUNCE_MS = 200 +const SERVER_RESTART_TIMEOUT_MS = 45_000 + +function loadErrorTitle(error: PreviewLoadErrorState): string { + const description = error.description.toLowerCase() + + if (description.includes('module script') || description.includes('mime type')) { + return 'Preview app failed to boot' + } + + if (description.includes('connection') || description.includes('refused') || description.includes('not found')) { + return 'Server not found' + } + + return 'Preview failed to load' +} + +function isModuleMimeError(message: string): boolean { + const lower = message.toLowerCase() + + return lower.includes('failed to load module script') && lower.includes('mime type') +} + +function PreviewLoadError({ + consoleHeight = 0, + error, + onRestartServer, + onRetry, + restarting +}: { + consoleHeight?: number + error: PreviewLoadErrorState + onRestartServer?: () => void + onRetry: () => void + restarting?: boolean +}) { + return ( + <PreviewEmptyState + body={ + <> + <a + className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground" + href={error.url} + onClick={event => { + event.preventDefault() + void window.hermesDesktop?.openExternal(error.url) + }} + > + {compactUrl(error.url)} + {error.code ? ` (${error.code})` : ''} + </a> + <div className="mt-1 text-[0.6875rem] text-muted-foreground/70">{error.description}</div> + </> + } + consoleHeight={consoleHeight} + primaryAction={{ label: 'Try again', onClick: onRetry }} + secondaryAction={ + onRestartServer + ? { + disabled: restarting, + label: restarting ? 'Hermes is restarting...' : 'Ask Hermes to restart the server', + onClick: onRestartServer + } + : undefined + } + title={loadErrorTitle(error)} + /> + ) +} + +const TITLEBAR_GROUP_ID = 'preview' + +export function PreviewPane({ + embedded = false, + onRestartServer, + reloadRequest = 0, + setTitlebarToolGroup, + target +}: PreviewPaneProps) { + const [consoleState] = useState(() => createPreviewConsoleState()) + const consoleBodyRef = useRef<HTMLDivElement | null>(null) + const consoleShouldStickRef = useRef(true) + const hostRef = useRef<HTMLDivElement | null>(null) + const lastReloadRequestRef = useRef(reloadRequest) + const lastRestartEventRef = useRef('') + const previewContentRef = useRef<HTMLDivElement | null>(null) + const webviewRef = useRef<PreviewWebview | null>(null) + const previewServerRestart = useStore($previewServerRestart) + const consoleHeight = useStore(consoleState.$height) + const consoleOpen = useStore(consoleState.$open) + const [currentUrl, setCurrentUrl] = useState(target.url) + const [devtoolsOpen, setDevtoolsOpen] = useState(false) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState<PreviewLoadErrorState | null>(null) + const [localReloadKey, setLocalReloadKey] = useState(0) + const isWebPreview = target.kind === 'url' || (target.previewKind === 'html' && target.renderMode !== 'source') + const currentLabel = compactUrl(currentUrl) + + const previewLabel = + target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel + + const restartingServer = + previewServerRestart?.status === 'running' && + (previewServerRestart.url === target.url || previewServerRestart.url === currentUrl) + + const startConsoleResize = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + event.preventDefault() + + const handle = event.currentTarget + const pointerId = event.pointerId + const startY = event.clientY + const startHeight = consoleHeight + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + let active = true + + handle.setPointerCapture?.(pointerId) + + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + + const handleMove = (moveEvent: PointerEvent) => { + if (!active) { + return + } + + consoleState.setHeight(clampConsoleHeight(startHeight + startY - moveEvent.clientY)) + } + + const cleanup = () => { + if (!active) { + return + } + + active = false + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + handle.releasePointerCapture?.(pointerId) + window.removeEventListener('pointermove', handleMove, true) + window.removeEventListener('pointerup', cleanup, true) + window.removeEventListener('pointercancel', cleanup, true) + window.removeEventListener('blur', cleanup) + handle.removeEventListener('lostpointercapture', cleanup) + } + + window.addEventListener('pointermove', handleMove, true) + window.addEventListener('pointerup', cleanup, true) + window.addEventListener('pointercancel', cleanup, true) + window.addEventListener('blur', cleanup) + handle.addEventListener('lostpointercapture', cleanup) + }, + [consoleHeight, consoleState] + ) + + const reloadPreview = useCallback(() => { + setLoadError(null) + + if (!isWebPreview) { + setLocalReloadKey(key => key + 1) + + return + } + + if (webviewRef.current?.reloadIgnoringCache) { + webviewRef.current.reloadIgnoringCache() + } else { + webviewRef.current?.reload?.() + } + }, [isWebPreview]) + + const appendConsoleEntry = useCallback( + (entry: Omit<ConsoleEntry, 'id'>) => { + consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current) + consoleState.append(entry) + }, + [consoleState] + ) + + const restartServer = useCallback(async () => { + if (!onRestartServer) { + return + } + + // Auto-open the preview console so the user can see progress events + // streaming back from the background agent. Without this, clicking + // "Ask Hermes to restart the server" looked like it did nothing — + // the work was happening, but in a collapsed pane. + consoleState.setOpen(true) + + try { + const context = consoleState.$logs.get().slice(-12).map(formatLogLine).join('\n') + const taskId = await onRestartServer(currentUrl, context || undefined) + + appendConsoleEntry({ + level: 1, + message: `Hermes is looking for a preview server to restart (${taskId})` + }) + + notify({ + kind: 'info', + title: 'Restarting preview server', + message: 'Hermes is working in the background. Watch the preview console for progress.', + durationMs: 4000 + }) + } catch (error) { + appendConsoleEntry({ + level: 2, + message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}` + }) + notifyError(error, 'Server restart failed') + } + }, [appendConsoleEntry, consoleState, currentUrl, onRestartServer]) + + const toggleDevTools = useCallback(() => { + const webview = webviewRef.current + + if (!webview?.openDevTools) { + return + } + + if (webview.isDevToolsOpened?.()) { + webview.closeDevTools?.() + setDevtoolsOpen(false) + + return + } + + webview.openDevTools() + setDevtoolsOpen(true) + }, []) + + useEffect(() => { + if (!setTitlebarToolGroup) { + return + } + + const tools: TitlebarTool[] = [ + ...(isWebPreview + ? [ + { + active: consoleOpen, + icon: <PreviewConsoleTitlebarIcon consoleState={consoleState} />, + id: `${TITLEBAR_GROUP_ID}-console`, + label: consoleOpen ? 'Hide preview console' : 'Show preview console', + onSelect: () => consoleState.setOpen(open => !open) + }, + { + active: devtoolsOpen, + icon: <Bug />, + id: `${TITLEBAR_GROUP_ID}-devtools`, + label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools', + onSelect: toggleDevTools + } + ] + : []) + ] + + setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools) + + return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, []) + }, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools]) + + useEffect(() => { + if (!consoleOpen) { + return + } + + consoleShouldStickRef.current = true + + const handle = window.requestAnimationFrame(() => { + const consoleBody = consoleBodyRef.current + consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + }) + + return () => window.cancelAnimationFrame(handle) + }, [consoleOpen]) + + useEffect(() => { + if ( + !previewServerRestart || + !previewServerRestart.message || + (previewServerRestart.url !== target.url && previewServerRestart.url !== currentUrl) + ) { + return + } + + const eventKey = `${previewServerRestart.taskId}:${previewServerRestart.status}:${previewServerRestart.message || ''}` + + if (eventKey === lastRestartEventRef.current) { + return + } + + lastRestartEventRef.current = eventKey + appendConsoleEntry({ + level: previewServerRestart.status === 'error' ? 2 : 1, + message: + previewServerRestart.status === 'running' + ? previewServerRestart.message + : previewServerRestart.status === 'complete' + ? `Hermes finished restarting the preview server${ + previewServerRestart.message ? `: ${previewServerRestart.message}` : '' + }` + : `Server restart failed: ${previewServerRestart.message || 'unknown error'}` + }) + + if (previewServerRestart.status === 'complete') { + reloadPreview() + notify({ + kind: 'success', + title: 'Preview server restarted', + message: previewServerRestart.message?.slice(0, 160) || 'Reloading the preview now.', + durationMs: 3500 + }) + } else if (previewServerRestart.status === 'error') { + notify({ + kind: 'warning', + title: 'Preview restart failed', + message: previewServerRestart.message?.slice(0, 200) || 'Hermes could not restart the server.', + durationMs: 6000 + }) + } + }, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url]) + + useEffect(() => { + if (!restartingServer || !previewServerRestart) { + return + } + + const taskId = previewServerRestart.taskId + + const timer = window.setTimeout(() => { + failPreviewServerRestart( + taskId, + 'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.' + ) + }, SERVER_RESTART_TIMEOUT_MS) + + return () => window.clearTimeout(timer) + }, [previewServerRestart, restartingServer]) + + useEffect(() => { + if (reloadRequest === lastReloadRequestRef.current) { + return + } + + lastReloadRequestRef.current = reloadRequest + + if (target.kind !== 'url') { + return + } + + appendConsoleEntry({ + level: 1, + message: 'Workspace changed, reloading preview' + }) + reloadPreview() + }, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind]) + + useEffect(() => { + if ( + target.kind !== 'file' || + !window.hermesDesktop?.watchPreviewFile || + !window.hermesDesktop?.onPreviewFileChanged + ) { + return + } + + let active = true + let pendingReloadCount = 0 + let pendingReloadUrl = '' + let reloadTimer: ReturnType<typeof setTimeout> | null = null + let watchId = '' + + const flushReload = () => { + if (!active || pendingReloadCount === 0) { + return + } + + const changedCount = pendingReloadCount + const changedUrl = pendingReloadUrl + + pendingReloadCount = 0 + pendingReloadUrl = '' + + appendConsoleEntry({ + level: 1, + message: + changedCount === 1 + ? `File changed, reloading preview: ${compactUrl(changedUrl)}` + : `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}` + }) + + reloadPreview() + } + + const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => { + if (!active || payload.id !== watchId) { + return + } + + pendingReloadCount += 1 + pendingReloadUrl = payload.url + + if (reloadTimer) { + clearTimeout(reloadTimer) + } + + reloadTimer = setTimeout(() => { + reloadTimer = null + flushReload() + }, FILE_RELOAD_DEBOUNCE_MS) + }) + + void window.hermesDesktop + .watchPreviewFile(target.url) + .then(watch => { + if (!active) { + void window.hermesDesktop?.stopPreviewFileWatch?.(watch.id) + + return + } + + watchId = watch.id + }) + .catch(error => { + appendConsoleEntry({ + level: 2, + message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}` + }) + }) + + return () => { + active = false + unsubscribe() + + if (reloadTimer) { + clearTimeout(reloadTimer) + } + + if (watchId) { + void window.hermesDesktop?.stopPreviewFileWatch?.(watchId) + } + } + }, [appendConsoleEntry, reloadPreview, target.kind, target.url]) + + useEffect(() => { + const host = hostRef.current + + if (!host) { + return + } + + host.replaceChildren() + webviewRef.current = null + setCurrentUrl(target.url) + setDevtoolsOpen(false) + setLoadError(null) + consoleState.reset() + setLoading(true) + + if (!isWebPreview) { + setLoading(false) + + return + } + + const webview = document.createElement('webview') as PreviewWebview + webview.className = 'flex h-full w-full flex-1 bg-transparent' + webview.setAttribute('partition', 'persist:hermes-preview') + webview.setAttribute('src', target.url) + webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes') + + const onConsole = (event: Event) => { + const detail = event as Event & { + level?: number + line?: number + message?: string + sourceId?: string + } + + const message = detail.message || '' + + appendConsoleEntry({ + level: detail.level ?? 0, + line: detail.line, + message, + source: detail.sourceId + }) + + if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) { + setLoadError({ + description: + 'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.', + url: webview.getURL?.() || target.url + }) + setLoading(false) + } + } + + const onNavigate = (event: Event) => { + const detail = event as Event & { url?: string } + + if (detail.url) { + setLoadError(null) + setCurrentUrl(detail.url) + } + } + + const onFail = (event: Event) => { + const detail = event as Event & { + errorCode?: number + errorDescription?: string + validatedURL?: string + } + + const errorCode = detail.errorCode + + if (errorCode === -3) { + return + } + + appendConsoleEntry({ + level: 3, + message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${ + detail.errorDescription || detail.validatedURL || 'unknown error' + }` + }) + setLoadError({ + code: errorCode, + description: detail.errorDescription || 'The preview page could not be reached.', + url: detail.validatedURL || webview.getURL?.() || target.url + }) + setLoading(false) + } + + const onStart = () => setLoading(true) + const onStop = () => setLoading(false) + + webview.addEventListener('console-message', onConsole) + webview.addEventListener('did-fail-load', onFail) + webview.addEventListener('did-navigate', onNavigate) + webview.addEventListener('did-navigate-in-page', onNavigate) + webview.addEventListener('did-start-loading', onStart) + webview.addEventListener('did-stop-loading', onStop) + host.appendChild(webview) + webviewRef.current = webview + + return () => { + webview.removeEventListener('console-message', onConsole) + webview.removeEventListener('did-fail-load', onFail) + webview.removeEventListener('did-navigate', onNavigate) + webview.removeEventListener('did-navigate-in-page', onNavigate) + webview.removeEventListener('did-start-loading', onStart) + webview.removeEventListener('did-stop-loading', onStop) + webview.remove() + } + }, [appendConsoleEntry, consoleState, isWebPreview, target.url]) + + return ( + <aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground"> + <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> + {!embedded && ( + <div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1"> + <div className="min-w-0 flex-1"> + <a + className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline" + href={currentUrl} + rel="noreferrer" + target="_blank" + title={`Open ${currentUrl}`} + > + {previewLabel || 'Preview'} + </a> + </div> + </div> + )} + + <div + className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-transparent" + ref={previewContentRef} + > + <div + className={cn( + 'absolute inset-0 flex bg-transparent', + (!isWebPreview || loadError) && 'pointer-events-none opacity-0' + )} + ref={hostRef} + /> + {!isWebPreview && <LocalFilePreview reloadKey={localReloadKey} target={target} />} + {loadError && ( + <PreviewLoadError + consoleHeight={consoleOpen ? consoleHeight : 0} + error={loadError} + onRestartServer={target.kind === 'url' && onRestartServer ? () => void restartServer() : undefined} + onRetry={reloadPreview} + restarting={restartingServer} + /> + )} + + {isWebPreview && consoleOpen && ( + <PreviewConsolePanel + consoleBodyRef={consoleBodyRef} + consoleShouldStickRef={consoleShouldStickRef} + consoleState={consoleState} + startConsoleResize={startConsoleResize} + /> + )} + </div> + </div> + </aside> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx new file mode 100644 index 000000000..268b9c41c --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -0,0 +1,153 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' +import { + $rightRailActiveTabId, + RIGHT_RAIL_PREVIEW_TAB_ID, + type RightRailTabId, + selectRightRailTab +} from '@/store/layout' +import { + $filePreviewTabs, + $previewReloadRequest, + $previewTarget, + closeRightRail, + closeRightRailTab, + type PreviewTarget +} from '@/store/preview' + +import { PreviewPane } from './preview-pane' + +export const PREVIEW_RAIL_MIN_WIDTH = '18rem' +export const PREVIEW_RAIL_MAX_WIDTH = '38rem' + +const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)` + +// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor +// against --chat-min-width so the chat surface never gets squeezed below it. +// Subtracts the project browser width so preview yields rather than crushing +// the chat when both right-side panes are open. +export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))` + +interface ChatPreviewRailProps { + onRestartServer?: (url: string, context?: string) => Promise<string> + setTitlebarToolGroup?: SetTitlebarToolGroup +} + +interface RailTab { + id: RightRailTabId + label: string + target: PreviewTarget +} + +function tabLabelFor(target: PreviewTarget): string { + const value = target.label || target.path || target.source || target.url + const tail = value.split(/[\\/]/).filter(Boolean).at(-1) + + return tail || value || 'Preview' +} + +export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) { + const previewReloadRequest = useStore($previewReloadRequest) + const activeTabId = useStore($rightRailActiveTabId) + const filePreviewTabs = useStore($filePreviewTabs) + const previewTarget = useStore($previewTarget) + + const tabs = useMemo<readonly RailTab[]>( + () => [ + ...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: 'Preview', target: previewTarget } as RailTab] : []), + ...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab) + ], + [filePreviewTabs, previewTarget] + ) + + const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0] + + useEffect(() => { + if (activeTab && activeTab.id !== activeTabId) { + selectRightRailTab(activeTab.id) + } + }, [activeTab, activeTabId]) + + if (!activeTab) { + return null + } + + const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID + + return ( + <aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)"> + <div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)"> + <div + className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + role="tablist" + > + {tabs.map(tab => { + const active = tab.id === activeTab.id + + return ( + <div + className={cn( + 'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)', + active + ? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]' + : 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground' + )} + key={tab.id} + > + {active && ( + <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" /> + )} + <button + aria-selected={active} + className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none" + onClick={() => selectRightRailTab(tab.id)} + role="tab" + title={tab.label} + type="button" + > + <span className="block min-w-0 truncate">{tab.label}</span> + </button> + <span + aria-hidden="true" + className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100" + /> + <button + aria-label={`Close ${tab.label}`} + className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100" + onClick={() => closeRightRailTab(tab.id)} + title={`Close ${tab.label}`} + type="button" + > + <Codicon name="close" size="0.75rem" /> + </button> + </div> + ) + })} + </div> + <button + aria-label="Close preview pane" + className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]" + onClick={closeRightRail} + title="Close preview pane" + type="button" + > + <Codicon name="close" size="0.75rem" /> + </button> + </div> + + <div className="min-h-0 flex-1 overflow-hidden"> + <PreviewPane + embedded + onRestartServer={isPreview ? onRestartServer : undefined} + reloadRequest={previewReloadRequest} + setTitlebarToolGroup={setTitlebarToolGroup} + target={activeTab.target} + /> + </div> + </aside> + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx new file mode 100644 index 000000000..05cc5b41d --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -0,0 +1,734 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { useStore } from '@nanostores/react' +import type * as React from 'react' +import { useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { KbdGroup } from '@/components/ui/kbd' +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem +} from '@/components/ui/sidebar' +import { Skeleton } from '@/components/ui/skeleton' +import type { SessionInfo } from '@/hermes' +import { cn } from '@/lib/utils' +import { + $pinnedSessionIds, + $sidebarAgentsGrouped, + $sidebarOpen, + $sidebarPinsOpen, + $sidebarRecentsOpen, + pinSession, + reorderPinnedSession, + setSidebarAgentsGrouped, + setSidebarPinsOpen, + setSidebarRecentsOpen, + SIDEBAR_SESSIONS_PAGE_SIZE, + unpinSession +} from '@/store/layout' +import { + $selectedStoredSessionId, + $sessions, + $sessionsLoading, + $sessionsTotal, + $workingSessionIds +} from '@/store/session' + +import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' +import { SidebarPanelLabel } from '../../shell/sidebar-label' +import type { SidebarNavItem } from '../../types' + +import { SidebarSessionRow } from './session-row' +import { VirtualSessionList } from './virtual-session-list' + +const VIRTUALIZE_THRESHOLD = 25 + +const SIDEBAR_NAV: SidebarNavItem[] = [ + { id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' }, + { id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE }, + { id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE }, + { id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE } +] + +const WORKSPACE_PAGE = 5 +const WS_ID_PREFIX = 'workspace:' + +const wsId = (id: string) => `${WS_ID_PREFIX}${id}` +const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null) +const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded)) +const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0 + +function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { + if (!orderIds.length) { + return items + } + + const byId = new Map(items.map(item => [getId(item), item])) + const seen = new Set<string>() + const out: T[] = [] + + for (const id of orderIds) { + const item = byId.get(id) + + if (item) { + out.push(item) + seen.add(id) + } + } + + for (const item of items) { + if (!seen.has(getId(item))) { + out.push(item) + } + } + + return out +} + +const baseName = (path: string) => + path + .replace(/[/\\]+$/, '') + .split(/[/\\]/) + .filter(Boolean) + .pop() + +function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] { + const groups = new Map<string, SidebarSessionGroup>() + + for (const session of sessions) { + const path = session.cwd?.trim() || '' + const id = path || '__no_workspace__' + const label = baseName(path) || path || 'No workspace' + + const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] } + group.sessions.push(session) + groups.set(id, group) + } + + return [...groups.values()] +} + +function useSortableBindings(id: string) { + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) + + return { + dragging: isDragging, + dragHandleProps: { ...attributes, ...listeners }, + ref: setNodeRef, + reorderable: true as const, + style: { transform: CSS.Transform.toString(transform), transition } + } +} + +interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> { + currentView: AppView + onNavigate: (item: SidebarNavItem) => void + onLoadMoreSessions: () => void + onResumeSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void +} + +export function ChatSidebar({ + currentView, + onNavigate, + onLoadMoreSessions, + onResumeSession, + onDeleteSession +}: ChatSidebarProps) { + const sidebarOpen = useStore($sidebarOpen) + const agentsGrouped = useStore($sidebarAgentsGrouped) + const pinnedSessionIds = useStore($pinnedSessionIds) + const pinsOpen = useStore($sidebarPinsOpen) + const agentsOpen = useStore($sidebarRecentsOpen) + const selectedSessionId = useStore($selectedStoredSessionId) + const sessions = useStore($sessions) + const sessionsLoading = useStore($sessionsLoading) + const sessionsTotal = useStore($sessionsTotal) + const workingSessionIds = useStore($workingSessionIds) + const [agentOrderIds, setAgentOrderIds] = useState<string[]>([]) + const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([]) + + const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null + + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions]) + + const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions]) + const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds]) + + const visiblePinnedIds = useMemo( + () => pinnedSessionIds.filter(id => sessionsById.has(id)), + [pinnedSessionIds, sessionsById] + ) + + const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds]) + + const pinnedSessions = useMemo( + () => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean), + [visiblePinnedIds, sessionsById] + ) + + const unpinnedAgentSessions = useMemo( + () => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)), + [sortedSessions, visiblePinnedIdSet] + ) + + const agentSessions = useMemo( + () => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds), + [unpinnedAgentSessions, agentOrderIds] + ) + + const agentGroups = useMemo( + () => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds), + [agentSessions, workspaceOrderIds] + ) + + const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 + const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 + const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length) + const hasMoreSessions = knownSessionTotal > sortedSessions.length + const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length) + + const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) { + return + } + + const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id)) + + if (newIndex < 0) { + return + } + + reorderPinnedSession(String(active.id), newIndex) + } + + const handleAgentDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) { + return + } + + const activeId = String(active.id) + const overId = String(over.id) + const activeWs = parseWsId(activeId) + const overWs = parseWsId(overId) + + if (activeWs && overWs) { + const oldIdx = agentGroups.findIndex(g => g.id === activeWs) + const newIdx = agentGroups.findIndex(g => g.id === overWs) + + if (oldIdx < 0 || newIdx < 0) { + return + } + + setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id)) + + return + } + + if (activeWs || overWs) { + return + } + + const oldIdx = agentSessions.findIndex(s => s.id === activeId) + const newIdx = agentSessions.findIndex(s => s.id === overId) + + if (oldIdx < 0 || newIdx < 0) { + return + } + + setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id)) + } + + return ( + <Sidebar + className={cn( + 'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none', + sidebarOpen + ? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' + : 'pointer-events-none border-transparent bg-transparent opacity-0' + )} + collapsible="none" + > + <SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5"> + <SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]"> + <SidebarGroupContent> + <SidebarMenu className="gap-px"> + {SIDEBAR_NAV.map(item => { + const isInteractive = Boolean(item.action) || Boolean(item.route) + + const active = + (item.id === 'skills' && currentView === 'skills') || + (item.id === 'messaging' && currentView === 'messaging') || + (item.id === 'artifacts' && currentView === 'artifacts') + + return ( + <SidebarMenuItem key={item.id}> + <SidebarMenuButton + aria-disabled={!isInteractive} + className={cn( + 'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none', + active && + 'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!', + !isInteractive && + 'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit' + )} + onClick={() => onNavigate(item)} + tooltip={item.label} + type="button" + > + <item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" /> + {sidebarOpen && ( + <> + <span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span> + {item.id === 'new-session' && ( + <KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} /> + )} + </> + )} + </SidebarMenuButton> + </SidebarMenuItem> + ) + })} + </SidebarMenu> + </SidebarGroupContent> + </SidebarGroup> + + {sidebarOpen && showSessionSections && ( + <SidebarSessionsSection + activeSessionId={activeSidebarSessionId} + contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1" + dndSensors={dndSensors} + emptyState={<SidebarPinnedEmptyState />} + label="Pinned" + onDeleteSession={onDeleteSession} + onReorder={handlePinnedDragEnd} + onResumeSession={onResumeSession} + onToggle={() => setSidebarPinsOpen(!pinsOpen)} + onTogglePin={unpinSession} + open={pinsOpen} + pinned + rootClassName="shrink-0 p-0 pb-1" + sessions={pinnedSessions} + sortable={pinnedSessions.length > 1} + workingSessionIdSet={workingSessionIdSet} + /> + )} + + {sidebarOpen && showSessionSections && ( + <SidebarSessionsSection + activeSessionId={activeSidebarSessionId} + contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75" + dndSensors={dndSensors} + emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />} + footer={ + !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? ( + <SidebarLoadMoreRow + loading={sessionsLoading} + onClick={onLoadMoreSessions} + step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)} + /> + ) : null + } + forceEmptyState={showSessionSkeletons} + groups={agentsGrouped ? agentGroups : undefined} + headerAction={ + <Button + aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'} + className={cn( + 'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100', + agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100' + )} + onClick={event => { + event.stopPropagation() + setSidebarRecentsOpen(true) + setSidebarAgentsGrouped(!agentsGrouped) + }} + size="icon-xs" + title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'} + variant="ghost" + > + <Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" /> + </Button> + } + label="Agents" + labelMeta={countLabel(agentSessions.length, knownSessionTotal)} + onDeleteSession={onDeleteSession} + onReorder={handleAgentDragEnd} + onResumeSession={onResumeSession} + onToggle={() => setSidebarRecentsOpen(!agentsOpen)} + onTogglePin={pinSession} + open={agentsOpen} + pinned={false} + rootClassName="min-h-0 flex-1 p-0" + sessions={agentSessions} + sortable={agentSessions.length > 1} + workingSessionIdSet={workingSessionIdSet} + /> + )} + </SidebarContent> + </Sidebar> + ) +} + +interface SidebarSectionHeaderProps { + label: string + open: boolean + onToggle: () => void + action?: React.ReactNode + meta?: React.ReactNode +} + +function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) { + return ( + <div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5"> + <button + className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none" + onClick={onToggle} + type="button" + > + <SidebarPanelLabel>{label}</SidebarPanelLabel> + {meta && <SidebarCount>{meta}</SidebarCount>} + <DisclosureCaret + className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100" + open={open} + /> + </button> + {action} + </div> + ) +} + +function SidebarSessionSkeletons() { + return ( + <div aria-hidden="true" className="grid gap-px"> + {['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => ( + <div className="grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg" key={`${width}-${i}`}> + <Skeleton className={cn('h-3.5 rounded-full', width)} /> + <Skeleton className="mx-auto size-4 rounded-md opacity-60" /> + </div> + ))} + </div> + ) +} + +const SidebarAllPinnedState = () => ( + <div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)"> + Everything here is pinned. Unpin a chat to show it in recents. + </div> +) + +function SidebarPinnedEmptyState() { + return ( + <div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)"> + <span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)"> + <Codicon name="pin" size="0.75rem" /> + </span> + <span>Shift click to pin a chat</span> + </div> + ) +} + +interface SidebarSessionGroup { + id: string + label: string + path: null | string + sessions: SessionInfo[] +} + +interface SidebarSessionsSectionProps { + label: string + open: boolean + onToggle: () => void + sessions: SessionInfo[] + activeSessionId: null | string + workingSessionIdSet: Set<string> + onResumeSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void + onTogglePin: (sessionId: string) => void + pinned: boolean + rootClassName?: string + contentClassName?: string + emptyState: React.ReactNode + forceEmptyState?: boolean + headerAction?: React.ReactNode + footer?: React.ReactNode + groups?: SidebarSessionGroup[] + labelMeta?: React.ReactNode + sortable?: boolean + onReorder?: (event: DragEndEvent) => void + dndSensors?: ReturnType<typeof useSensors> +} + +function SidebarSessionsSection({ + label, + open, + onToggle, + sessions, + activeSessionId, + workingSessionIdSet, + onResumeSession, + onDeleteSession, + onTogglePin, + pinned, + rootClassName, + contentClassName, + emptyState, + forceEmptyState = false, + headerAction, + footer, + groups, + labelMeta, + sortable = false, + onReorder, + dndSensors +}: SidebarSessionsSectionProps) { + const showEmptyState = forceEmptyState || sessions.length === 0 + const dndActive = sortable && !!onReorder + + const renderRow = (session: SessionInfo) => { + const rowProps = { + isPinned: pinned, + isSelected: session.id === activeSessionId, + isWorking: workingSessionIdSet.has(session.id), + onDelete: () => onDeleteSession(session.id), + onPin: () => onTogglePin(session.id), + onResume: () => onResumeSession(session.id), + session + } + + return sortable ? ( + <SortableSidebarSessionRow key={session.id} {...rowProps} /> + ) : ( + <SidebarSessionRow key={session.id} {...rowProps} /> + ) + } + + const renderRows = (items: SessionInfo[]) => items.map(renderRow) + + const renderSessionList = (items: SessionInfo[]) => + dndActive ? ( + <SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}> + {renderRows(items)} + </SortableContext> + ) : ( + renderRows(items) + ) + + const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD + + let inner: React.ReactNode + + if (showEmptyState) { + inner = emptyState + } else if (groups?.length) { + const groupNodes = groups.map(group => + dndActive ? ( + <SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} /> + ) : ( + <SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} /> + ) + ) + + inner = dndActive ? ( + <SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}> + {groupNodes} + </SortableContext> + ) : ( + groupNodes + ) + } else if (flatVirtualized) { + inner = ( + <VirtualSessionList + activeSessionId={activeSessionId} + onDeleteSession={onDeleteSession} + onResumeSession={onResumeSession} + onTogglePin={onTogglePin} + pinned={pinned} + sessions={sessions} + sortable={sortable} + workingSessionIdSet={workingSessionIdSet} + /> + ) + } else { + inner = renderSessionList(sessions) + } + + const body = + dndActive && !showEmptyState ? ( + <DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}> + {inner} + </DndContext> + ) : ( + inner + ) + + // The virtualizer owns its own scroller, so suppress the wrapper's overflow + // to avoid a double scroll container. + const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible') + + return ( + <SidebarGroup className={rootClassName}> + <SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} /> + {open && ( + <SidebarGroupContent className={resolvedContentClassName}> + {body} + {footer} + </SidebarGroupContent> + )} + </SidebarGroup> + ) +} + +interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> { + group: SidebarSessionGroup + renderRows: (sessions: SessionInfo[]) => React.ReactNode + reorderable?: boolean + dragging?: boolean + dragHandleProps?: React.HTMLAttributes<HTMLElement> +} + +function SidebarWorkspaceGroup({ + group, + renderRows, + reorderable = false, + dragging = false, + dragHandleProps, + className, + style, + ref, + ...rest +}: SidebarWorkspaceGroupProps) { + const [open, setOpen] = useState(true) + const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE) + const visibleSessions = group.sessions.slice(0, visibleCount) + const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length) + const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount) + + return ( + <div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}> + <button + className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)" + onClick={() => setOpen(value => !value)} + title={group.path ?? undefined} + type="button" + > + <span className="truncate">{group.label}</span> + <SidebarCount>{group.sessions.length}</SidebarCount> + <DisclosureCaret + className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100" + open={open} + /> + {reorderable && ( + <span + {...dragHandleProps} + aria-label={`Reorder workspace ${group.label}`} + className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing" + onClick={event => event.stopPropagation()} + > + <Codicon + className={cn( + 'text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/workspace:opacity-80 hover:text-(--ui-text-secondary)', + dragging && 'text-(--ui-text-secondary) opacity-100' + )} + name="grabber" + size="0.75rem" + /> + </span> + )} + </button> + {open && ( + <> + {renderRows(visibleSessions)} + {hiddenCount > 0 && ( + <button + aria-label={`Show ${nextCount} more in ${group.label}`} + className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground" + onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)} + title={`Show ${nextCount} more in ${group.label}`} + type="button" + > + <Codicon name="ellipsis" size="0.75rem" /> + </button> + )} + </> + )} + </div> + ) +} + +interface SortableWorkspaceProps { + group: SidebarSessionGroup + renderRows: (sessions: SessionInfo[]) => React.ReactNode +} + +function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) { + return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} /> +} + +function SidebarCount({ children }: { children: React.ReactNode }) { + return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span> +} + +interface SortableSessionRowProps { + session: SessionInfo + isPinned: boolean + isSelected: boolean + isWorking: boolean + onDelete: () => void + onPin: () => void + onResume: () => void +} + +function SortableSidebarSessionRow(props: SortableSessionRowProps) { + return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} /> +} + +interface SidebarLoadMoreRowProps { + loading: boolean + onClick: () => void + step: number +} + +function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) { + const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more' + + return ( + <button + className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)" + disabled={loading} + onClick={onClick} + type="button" + > + <Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} /> + <span>{label}</span> + </button> + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx new file mode 100644 index 000000000..65f8d8bf4 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -0,0 +1,239 @@ +import type * as React from 'react' +import { useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' +import { writeClipboardText } from '@/components/ui/copy-button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { renameSession } from '@/hermes' +import { triggerHaptic } from '@/lib/haptics' +import { exportSession } from '@/lib/session-export' +import { notify, notifyError } from '@/store/notifications' +import { setSessions } from '@/store/session' + +interface SessionActions { + sessionId: string + title: string + pinned?: boolean + onPin?: () => void + onDelete?: () => void +} + +type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem + +interface ItemSpec { + className?: string + disabled: boolean + icon: string + label: string + onSelect: (event: Event) => void + variant?: 'destructive' +} + +function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) { + const [renameOpen, setRenameOpen] = useState(false) + + const items: ItemSpec[] = [ + { + disabled: !onPin, + icon: 'pin', + label: pinned ? 'Unpin' : 'Pin', + onSelect: () => { + triggerHaptic('selection') + onPin?.() + } + }, + { + disabled: !sessionId, + icon: 'copy', + label: 'Copy ID', + onSelect: event => { + event.preventDefault() + triggerHaptic('selection') + void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID')) + } + }, + { + disabled: !sessionId, + icon: 'cloud-download', + label: 'Export', + onSelect: () => { + triggerHaptic('selection') + void exportSession(sessionId, { title }) + } + }, + { + disabled: !sessionId, + icon: 'edit', + label: 'Rename', + onSelect: () => { + triggerHaptic('selection') + setRenameOpen(true) + } + }, + { + className: 'text-destructive focus:text-destructive', + disabled: !onDelete, + icon: 'trash', + label: 'Delete', + onSelect: () => { + triggerHaptic('warning') + onDelete?.() + }, + variant: 'destructive' + } + ] + + const renderItems = (Item: MenuItem) => + items.map(({ className, disabled, icon, label, onSelect, variant }) => ( + <Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}> + <Codicon name={icon} size="0.875rem" /> + <span>{label}</span> + </Item> + )) + + const renameDialog = ( + <RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} /> + ) + + return { renameDialog, renderItems } +} + +interface SessionActionsMenuProps + extends SessionActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> { + children: React.ReactNode +} + +export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) { + const { renameDialog, renderItems } = useSessionActions(actions) + + return ( + <> + <DropdownMenu> + <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> + <DropdownMenuContent + align={align} + aria-label={`Actions for ${actions.title}`} + className="w-40" + sideOffset={sideOffset} + > + {renderItems(DropdownMenuItem)} + </DropdownMenuContent> + </DropdownMenu> + {renameDialog} + </> + ) +} + +interface SessionContextMenuProps extends SessionActions { + children: React.ReactNode +} + +export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) { + const { renameDialog, renderItems } = useSessionActions(actions) + + return ( + <> + <ContextMenu> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> + <ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40"> + {renderItems(ContextMenuItem)} + </ContextMenuContent> + </ContextMenu> + {renameDialog} + </> + ) +} + +interface RenameSessionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + sessionId: string + currentTitle: string +} + +function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) { + const [value, setValue] = useState(currentTitle) + const [submitting, setSubmitting] = useState(false) + const inputRef = useRef<HTMLInputElement>(null) + + useEffect(() => { + if (open) { + setValue(currentTitle) + window.setTimeout(() => inputRef.current?.select(), 0) + } + }, [currentTitle, open]) + + const submit = async () => { + const next = value.trim() + + if (!sessionId || submitting) { + return + } + + if (next === currentTitle.trim()) { + onOpenChange(false) + + return + } + + setSubmitting(true) + + try { + const result = await renameSession(sessionId, next) + const finalTitle = result.title || next || '' + setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) + notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' }) + onOpenChange(false) + } catch (err) { + notifyError(err, 'Rename failed') + } finally { + setSubmitting(false) + } + } + + return ( + <Dialog onOpenChange={onOpenChange} open={open}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Rename session</DialogTitle> + <DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription> + </DialogHeader> + <Input + autoFocus + disabled={submitting} + onChange={event => setValue(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } else if (event.key === 'Escape') { + onOpenChange(false) + } + }} + placeholder="Untitled session" + ref={inputRef} + value={value} + /> + <DialogFooter> + <Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost"> + Cancel + </Button> + <Button disabled={submitting} onClick={() => void submit()} type="button"> + Save + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx new file mode 100644 index 000000000..f4059cb04 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -0,0 +1,161 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import type { SessionInfo } from '@/hermes' +import { sessionTitle } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' + +import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu' + +interface SidebarSessionRowProps extends React.ComponentProps<'div'> { + session: SessionInfo + isPinned: boolean + isSelected: boolean + isWorking: boolean + onDelete: () => void + onPin: () => void + onResume: () => void + reorderable?: boolean + dragging?: boolean + dragHandleProps?: React.HTMLAttributes<HTMLElement> +} + +const AGE_TICKS: ReadonlyArray<[number, string]> = [ + [86_400_000, 'd'], + [3_600_000, 'h'], + [60_000, 'm'] +] + +function formatAge(seconds: number): string { + const delta = Math.max(0, Date.now() - seconds * 1000) + + for (const [ms, suffix] of AGE_TICKS) { + if (delta >= ms) { + return `${Math.floor(delta / ms)}${suffix}` + } + } + + return 'now' +} + +export function SidebarSessionRow({ + session, + isPinned, + isSelected, + isWorking, + onDelete, + onPin, + onResume, + reorderable = false, + dragging = false, + dragHandleProps, + className, + style, + ref, + ...rest +}: SidebarSessionRowProps) { + const title = sessionTitle(session) + const age = formatAge(session.last_active || session.started_at) + const handleLabel = `Reorder ${title}` + + return ( + <SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}> + <div + className={cn( + 'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none', + isSelected && 'bg-(--ui-row-active-background)', + isWorking && 'text-foreground', + dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm', + className + )} + data-working={isWorking ? 'true' : undefined} + ref={ref} + style={style} + {...rest} + > + {isWorking && <span aria-hidden="true" className="arc-border" />} + <button + className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12" + onClick={event => { + if (event.shiftKey) { + event.preventDefault() + event.stopPropagation() + triggerHaptic('selection') + onPin() + + return + } + + onResume() + }} + type="button" + > + {reorderable ? ( + <span + {...dragHandleProps} + aria-label={handleLabel} + className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing" + onClick={event => event.stopPropagation()} + > + <SidebarRowDot + className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" + isWorking={isWorking} + /> + <Codicon + className={cn( + 'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)', + dragging && 'text-(--ui-text-secondary) opacity-100' + )} + name="grabber" + size="0.75rem" + /> + </span> + ) : ( + <span className="grid w-3.5 shrink-0 place-items-center overflow-hidden"> + <SidebarRowDot isWorking={isWorking} /> + </span> + )} + <span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90"> + {title} + </span> + </button> + <div className="relative z-2 grid w-[1.375rem] place-items-center"> + {!isWorking && ( + <span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100"> + {age} + </span> + )} + <SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}> + <Button + aria-label={`Actions for ${title}`} + className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!" + size="icon" + title="Session actions" + variant="ghost" + > + <Codicon name="ellipsis" size="0.875rem" /> + </Button> + </SessionActionsMenu> + </div> + </div> + </SessionContextMenu> + ) +} + +function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) { + return ( + <span + aria-label={isWorking ? 'Session running' : undefined} + className={cn( + 'rounded-full', + isWorking + ? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']" + : 'size-1 bg-(--ui-text-quaternary) opacity-80', + className + )} + role={isWorking ? 'status' : undefined} + /> + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx new file mode 100644 index 000000000..7613c6217 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -0,0 +1,149 @@ +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { useVirtualizer } from '@tanstack/react-virtual' +import { type FC, useCallback, useMemo, useRef } from 'react' + +import type { SessionInfo } from '@/hermes' +import { cn } from '@/lib/utils' + +import { SidebarSessionRow } from './session-row' + +interface SessionRowCommonProps { + isPinned: boolean + isSelected: boolean + isWorking: boolean + onDelete: () => void + onPin: () => void + onResume: () => void +} + +interface VirtualSessionListProps { + activeSessionId: null | string + className?: string + onDeleteSession: (sessionId: string) => void + onResumeSession: (sessionId: string) => void + onTogglePin: (sessionId: string) => void + pinned: boolean + sessions: SessionInfo[] + sortable: boolean + workingSessionIdSet: Set<string> +} + +const ROW_ESTIMATE_PX = 28 +const OVERSCAN_ROWS = 12 + +export const VirtualSessionList: FC<VirtualSessionListProps> = ({ + activeSessionId, + className, + onDeleteSession, + onResumeSession, + onTogglePin, + pinned, + sessions, + sortable, + workingSessionIdSet +}) => { + const scrollerRef = useRef<HTMLDivElement | null>(null) + const ids = useMemo(() => sessions.map(s => s.id), [sessions]) + + const virtualizer = useVirtualizer({ + count: sessions.length, + estimateSize: () => ROW_ESTIMATE_PX, + getItemKey: index => sessions[index]?.id ?? index, + getScrollElement: () => scrollerRef.current, + // jsdom-friendly default; the real rect takes over on first observe. + initialRect: { height: 600, width: 240 }, + overscan: OVERSCAN_ROWS + }) + + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + const paddingTop = virtualItems[0]?.start ?? 0 + const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)) + + const rows = virtualItems.map(virtualItem => { + const session = sessions[virtualItem.index] + + if (!session) { + return null + } + + const commonProps: SessionRowCommonProps = { + isPinned: pinned, + isSelected: session.id === activeSessionId, + isWorking: workingSessionIdSet.has(session.id), + onDelete: () => onDeleteSession(session.id), + onPin: () => onTogglePin(session.id), + onResume: () => onResumeSession(session.id) + } + + return sortable ? ( + <VirtualSortableRow + index={virtualItem.index} + key={session.id} + measureRef={virtualizer.measureElement} + rowProps={commonProps} + session={session} + /> + ) : ( + <SidebarSessionRow + {...commonProps} + data-index={virtualItem.index} + key={session.id} + ref={virtualizer.measureElement} + session={session} + /> + ) + }) + + const list = ( + <div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}> + <div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}> + {rows} + </div> + </div> + ) + + return sortable ? ( + <SortableContext items={ids} strategy={verticalListSortingStrategy}> + {list} + </SortableContext> + ) : ( + list + ) +} + +interface VirtualSortableRowProps { + index: number + measureRef: (node: Element | null) => void + rowProps: SessionRowCommonProps + session: SessionInfo +} + +function VirtualSortableRow({ index, measureRef, rowProps, session }: VirtualSortableRowProps) { + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: session.id }) + + // Merge dnd-kit's setNodeRef with the virtualizer's measureElement so + // the row participates in both DnD hit-testing and TanStack height + // measurement. + const refMerged = useCallback( + (node: HTMLDivElement | null) => { + setNodeRef(node) + measureRef(node) + }, + [measureRef, setNodeRef] + ) + + return ( + <SidebarSessionRow + {...rowProps} + data-index={index} + dragging={isDragging} + dragHandleProps={{ ...attributes, ...listeners }} + ref={refMerged} + reorderable + session={session} + style={{ transform: CSS.Transform.toString(transform), transition }} + /> + ) +} diff --git a/apps/desktop/src/app/chat/thread-loading.test.ts b/apps/desktop/src/app/chat/thread-loading.test.ts new file mode 100644 index 000000000..63ddf98b3 --- /dev/null +++ b/apps/desktop/src/app/chat/thread-loading.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import type { ChatMessage } from '@/lib/chat-messages' + +import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' + +function message(id: string, role: ChatMessage['role'], hidden = false): ChatMessage { + return { + id, + role, + parts: [{ type: 'text', text: `${role}:${id}` }], + hidden + } +} + +describe('thread loading state', () => { + it('returns session when routed session is still hydrating', () => { + expect(threadLoadingState(true, true, true, false)).toBe('session') + }) + + it('returns response while awaiting an assistant reply to the last visible user message', () => { + const messages = [message('u1', 'user'), message('a1', 'assistant', true)] + + expect(lastVisibleMessageIsUser(messages)).toBe(true) + expect(threadLoadingState(false, true, true, lastVisibleMessageIsUser(messages))).toBe('response') + }) + + it('does not show response loading when the last visible message is not user-authored', () => { + const messages = [message('u1', 'user'), message('a1', 'assistant')] + + expect(lastVisibleMessageIsUser(messages)).toBe(false) + expect(threadLoadingState(false, true, true, lastVisibleMessageIsUser(messages))).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/app/chat/thread-loading.ts b/apps/desktop/src/app/chat/thread-loading.ts new file mode 100644 index 000000000..97686c655 --- /dev/null +++ b/apps/desktop/src/app/chat/thread-loading.ts @@ -0,0 +1,26 @@ +import type { ChatMessage } from '@/lib/chat-messages' + +export type ThreadLoadingState = 'response' | 'session' + +export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean { + const lastVisible = [...messages].reverse().find(message => !message.hidden) + + return lastVisible?.role === 'user' +} + +export function threadLoadingState( + loadingSession: boolean, + busy: boolean, + awaitingResponse: boolean, + lastVisibleIsUser: boolean +): ThreadLoadingState | undefined { + if (loadingSession) { + return 'session' + } + + if (busy && awaitingResponse && lastVisibleIsUser) { + return 'response' + } + + return undefined +} diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx new file mode 100644 index 000000000..9e4e42ee6 --- /dev/null +++ b/apps/desktop/src/app/command-center/index.tsx @@ -0,0 +1,1282 @@ +import { useStore } from '@nanostores/react' +import { + IconBookmark, + IconBookmarkFilled, + IconDownload, + IconLoader2, + IconRefresh, + IconSparkles, + IconTrash +} from '@tabler/icons-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { + getActionStatus, + getAuxiliaryModels, + getGlobalModelInfo, + getGlobalModelOptions, + getLogs, + getStatus, + getUsageAnalytics, + restartGateway, + searchSessions, + setModelAssignment, + updateHermes +} from '@/hermes' +import type { + ActionStatusResponse, + AnalyticsResponse, + AuxiliaryModelsResponse, + ModelOptionProvider, + SessionInfo, + SessionSearchResult as SessionSearchApiResult, + StatusResponse +} from '@/hermes' +import { sessionTitle } from '@/lib/chat-runtime' +import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons' +import { exportSession } from '@/lib/session-export' +import { cn } from '@/lib/utils' +import { upsertDesktopActionTask } from '@/store/activity' +import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' +import { $sessions } from '@/store/session' + +import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton } from '../overlays/overlay-chrome' +import { OverlaySearchInput } from '../overlays/overlay-search-input' +import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' +import { OverlayView } from '../overlays/overlay-view' +import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes' + +export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage' + +const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[] + +// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and +// hints make the assignments panel readable; raw task keys (vision, mcp, …) +// are opaque to most users. +interface AuxTaskMeta { + hint: string + key: string + label: string +} + +const AUX_TASKS: readonly AuxTaskMeta[] = [ + { key: 'vision', label: 'Vision', hint: 'Image analysis' }, + { key: 'web_extract', label: 'Web extract', hint: 'Page summarization' }, + { key: 'compression', label: 'Compression', hint: 'Context compaction' }, + { key: 'session_search', label: 'Session search', hint: 'Recall queries' }, + { key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' }, + { key: 'approval', label: 'Approval', hint: 'Smart auto-approve' }, + { key: 'mcp', label: 'MCP', hint: 'MCP tool routing' }, + { key: 'title_generation', label: 'Title gen', hint: 'Session titles' }, + { key: 'curator', label: 'Curator', hint: 'Skill-usage review' } +] + +const USAGE_PERIODS = [7, 30, 90] as const +type UsagePeriod = (typeof USAGE_PERIODS)[number] + +interface CommandCenterViewProps { + initialSection?: CommandCenterSection + onClose: () => void + onDeleteSession: (sessionId: string) => Promise<void> + onMainModelChanged?: (provider: string, model: string) => void + onNavigateRoute: (path: string) => void + onOpenSession: (sessionId: string) => void +} + +const SECTION_LABELS: Record<CommandCenterSection, string> = { + sessions: 'Sessions', + system: 'System', + models: 'Models', + usage: 'Usage' +} + +const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = { + sessions: 'Search and manage sessions', + system: 'Status, logs, and system actions', + models: 'Global and auxiliary model controls', + usage: 'Token, cost, and skill activity over time' +} + +interface NavigationSearchEntry { + detail?: string + id: string + route: string + title: string +} + +interface SectionSearchEntry { + detail?: string + id: string + section: CommandCenterSection + title: string +} + +const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [ + { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' }, + { id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' }, + { id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' }, + { + id: 'nav-messaging', + route: MESSAGING_ROUTE, + title: 'Messaging', + detail: 'Set up Telegram, Slack, Discord, and more' + }, + { id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' } +] + +const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [ + { id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' }, + { id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' }, + { id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' }, + { id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' } +] + +interface SessionSearchHit { + detail?: string + kind: 'session' + sessionId: string + snippet: string + title: string +} + +interface RouteSearchHit { + detail?: string + kind: 'route' + route: string + title: string +} + +interface SectionSearchHit { + detail?: string + kind: 'section' + section: CommandCenterSection + title: string +} + +type CommandCenterSearchResult = RouteSearchHit | SectionSearchHit | SessionSearchHit + +interface CommandCenterSearchProvider { + id: string + label: string + search: (query: string) => Promise<CommandCenterSearchResult[]> +} + +interface CommandCenterSearchGroup { + id: string + label: string + results: CommandCenterSearchResult[] +} + +function formatTimestamp(value?: number | null): string { + if (!value) { + return '' + } + + const date = new Date(value * 1000) + + if (Number.isNaN(date.getTime())) { + return '' + } + + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date) +} + +function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map<string, SessionInfo>) { + const row = sessionsById.get(result.session_id) + const title = row ? sessionTitle(row) : result.session_id + const detail = [result.model, result.source].filter(Boolean).join(' · ') + + return { detail, title } +} + +function matchesSearchQuery(query: string, ...values: Array<string | undefined>): boolean { + const normalized = query.trim().toLowerCase() + + if (!normalized) { + return true + } + + return values.some(value => value?.toLowerCase().includes(normalized)) +} + +function useDebouncedValue<T>(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const id = window.setTimeout(() => setDebounced(value), delayMs) + + return () => window.clearTimeout(id) + }, [delayMs, value]) + + return debounced +} + +export function CommandCenterView({ + initialSection, + onClose, + onDeleteSession, + onMainModelChanged, + onNavigateRoute, + onOpenSession +}: CommandCenterViewProps) { + const sessions = useStore($sessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + + const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions') + + const [query, setQuery] = useState('') + const [searchLoading, setSearchLoading] = useState(false) + const [searchGroups, setSearchGroups] = useState<CommandCenterSearchGroup[]>([]) + const [status, setStatus] = useState<StatusResponse | null>(null) + const [logs, setLogs] = useState<string[]>([]) + const [systemLoading, setSystemLoading] = useState(false) + const [systemError, setSystemError] = useState('') + const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState('') + const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null) + const [providers, setProviders] = useState<ModelOptionProvider[]>([]) + const [selectedProvider, setSelectedProvider] = useState('') + const [selectedModel, setSelectedModel] = useState('') + const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null) + const [applyingModel, setApplyingModel] = useState(false) + const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null) + const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) + const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30) + const [usage, setUsage] = useState<AnalyticsResponse | null>(null) + const [usageLoading, setUsageLoading] = useState(false) + const [usageError, setUsageError] = useState('') + const searchRequestRef = useRef(0) + const usageRequestRef = useRef(0) + + const debouncedQuery = useDebouncedValue(query.trim(), 180) + + const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions]) + + const filteredSessions = useMemo( + () => + [...sessions].sort((a, b) => { + const left = a.last_active || a.started_at || 0 + const right = b.last_active || b.started_at || 0 + + return right - left + }), + [sessions] + ) + + const selectedProviderModels = useMemo( + () => providers.find(provider => provider.slug === selectedProvider)?.models ?? [], + [providers, selectedProvider] + ) + + const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>( + () => [ + { + id: 'navigation', + label: 'Navigate', + search: async searchQuery => { + const routeHits: RouteSearchHit[] = NAVIGATION_SEARCH_ENTRIES.filter(entry => + matchesSearchQuery(searchQuery, entry.title, entry.detail, entry.route) + ).map(entry => ({ + detail: entry.detail, + kind: 'route', + route: entry.route, + title: entry.title + })) + + const sectionHits: SectionSearchHit[] = SECTION_SEARCH_ENTRIES.filter(entry => + matchesSearchQuery(searchQuery, entry.title, entry.detail, SECTION_LABELS[entry.section]) + ).map(entry => ({ + detail: entry.detail, + kind: 'section', + section: entry.section, + title: entry.title + })) + + return [...routeHits, ...sectionHits] + } + }, + { + id: 'sessions', + label: 'Sessions', + search: async searchQuery => { + const response = await searchSessions(searchQuery) + + return response.results.map(result => { + const { detail, title } = splitSessionSearchResult(result, sessionsById) + + return { + detail, + kind: 'session', + sessionId: result.session_id, + snippet: result.snippet || '', + title + } satisfies SessionSearchHit + }) + } + } + ], + [sessionsById] + ) + + const refreshSystem = useCallback(async () => { + setSystemLoading(true) + setSystemError('') + + try { + const [nextStatus, nextLogs] = await Promise.all([ + getStatus(), + getLogs({ + file: 'agent', + lines: 120 + }) + ]) + + setStatus(nextStatus) + setLogs(nextLogs.lines) + } catch (error) { + setSystemError(error instanceof Error ? error.message : String(error)) + } finally { + setSystemLoading(false) + } + }, []) + + const refreshModels = useCallback(async () => { + setModelsLoading(true) + setModelsError('') + + try { + const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ + getGlobalModelInfo(), + getGlobalModelOptions(), + getAuxiliaryModels() + ]) + + setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) + setProviders(modelOptions.providers || []) + setSelectedProvider(prev => prev || modelInfo.provider) + setSelectedModel(prev => prev || modelInfo.model) + setAuxiliary(auxiliaryModels) + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setModelsLoading(false) + } + }, []) + + const refreshUsage = useCallback(async (days: UsagePeriod) => { + const requestId = usageRequestRef.current + 1 + usageRequestRef.current = requestId + setUsageLoading(true) + setUsageError('') + + try { + const response = await getUsageAnalytics(days) + + if (usageRequestRef.current === requestId) { + setUsage(response) + } + } catch (error) { + if (usageRequestRef.current === requestId) { + setUsageError(error instanceof Error ? error.message : String(error)) + } + } finally { + if (usageRequestRef.current === requestId) { + setUsageLoading(false) + } + } + }, []) + + useEffect(() => { + if (!debouncedQuery) { + setSearchGroups([]) + setSearchLoading(false) + + return + } + + const requestId = searchRequestRef.current + 1 + searchRequestRef.current = requestId + setSearchLoading(true) + + void Promise.all( + searchProviders.map(async provider => ({ + id: provider.id, + label: provider.label, + results: await provider.search(debouncedQuery) + })) + ) + .then(groups => { + if (searchRequestRef.current === requestId) { + setSearchGroups(groups.filter(group => group.results.length > 0)) + } + }) + .catch(() => { + if (searchRequestRef.current === requestId) { + setSearchGroups([]) + } + }) + .finally(() => { + if (searchRequestRef.current === requestId) { + setSearchLoading(false) + } + }) + }, [debouncedQuery, searchProviders]) + + useEffect(() => { + if (section === 'system' && !status && !systemLoading) { + void refreshSystem() + } + }, [refreshSystem, section, status, systemLoading]) + + useEffect(() => { + if (section === 'models' && !mainModel && !modelsLoading) { + void refreshModels() + } + }, [mainModel, modelsLoading, refreshModels, section]) + + useEffect(() => { + if (section === 'usage') { + void refreshUsage(usagePeriod) + } + }, [refreshUsage, section, usagePeriod]) + + useEffect(() => { + if (!selectedProviderModels.length) { + return + } + + if (!selectedProviderModels.includes(selectedModel)) { + setSelectedModel(selectedProviderModels[0]) + } + }, [selectedModel, selectedProviderModels]) + + const showGlobalSearchResults = debouncedQuery.length > 0 + const hasGlobalSearchResults = searchGroups.length > 0 + const sessionListHasResults = filteredSessions.length > 0 + + const runSystemAction = useCallback( + async (kind: 'restart' | 'update') => { + setSystemError('') + + try { + const started = kind === 'restart' ? await restartGateway() : await updateHermes() + let nextStatus: ActionStatusResponse | null = null + + for (let attempt = 0; attempt < 18; attempt += 1) { + await new Promise(resolve => window.setTimeout(resolve, 1200)) + const polled = await getActionStatus(started.name, 180) + nextStatus = polled + setSystemAction(polled) + upsertDesktopActionTask(polled) + + if (!polled.running) { + break + } + } + + if (!nextStatus) { + const pendingStatus = { + exit_code: null, + lines: ['Action started, waiting for status...'], + name: started.name, + pid: started.pid, + running: true + } + + setSystemAction(pendingStatus) + upsertDesktopActionTask(pendingStatus) + } + } catch (error) { + setSystemError(error instanceof Error ? error.message : String(error)) + } finally { + void refreshSystem() + } + }, + [refreshSystem] + ) + + const applyMainModel = useCallback(async () => { + if (!selectedProvider || !selectedModel) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + const result = await setModelAssignment({ + model: selectedModel, + provider: selectedProvider, + scope: 'main' + }) + + const provider = result.provider || selectedProvider + const model = result.model || selectedModel + setMainModel({ provider, model }) + onMainModelChanged?.(provider, model) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, [onMainModelChanged, refreshModels, selectedModel, selectedProvider]) + + const setAuxiliaryToMain = useCallback( + async (task: string) => { + if (!mainModel) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + await setModelAssignment({ + model: mainModel.model, + provider: mainModel.provider, + scope: 'auxiliary', + task + }) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, + [mainModel, refreshModels] + ) + + const applyAuxiliaryDraft = useCallback( + async (task: string) => { + if (!auxDraft.provider || !auxDraft.model) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + await setModelAssignment({ + model: auxDraft.model, + provider: auxDraft.provider, + scope: 'auxiliary', + task + }) + setEditingAuxTask(null) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, + [auxDraft, refreshModels] + ) + + const beginAuxiliaryEdit = useCallback( + (task: string) => { + const current = auxiliary?.tasks.find(entry => entry.task === task) + + const initialProvider = + current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '') + + const initialModel = current?.model || mainModel?.model || '' + setAuxDraft({ provider: initialProvider, model: initialModel }) + setEditingAuxTask(task) + }, + [auxiliary, mainModel] + ) + + const auxDraftProviderModels = useMemo( + () => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [], + [auxDraft.provider, providers] + ) + + const resetAuxiliaryModels = useCallback(async () => { + if (!mainModel) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + await setModelAssignment({ + model: mainModel.model, + provider: mainModel.provider, + scope: 'auxiliary', + task: '__reset__' + }) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, [mainModel, refreshModels]) + + const handleSearchSelect = useCallback( + (result: CommandCenterSearchResult) => { + if (result.kind === 'route') { + onNavigateRoute(result.route) + + return + } + + if (result.kind === 'section') { + setSection(result.section) + setQuery('') + + return + } + + onOpenSession(result.sessionId) + }, + [onNavigateRoute, onOpenSession, setSection] + ) + + return ( + <OverlayView + closeLabel="Close command center" + headerContent={ + <OverlaySearchInput + containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80" + loading={searchLoading} + onChange={next => setQuery(next)} + placeholder="Search sessions, views, and actions" + value={query} + /> + } + onClose={onClose} + > + <OverlaySplitLayout> + <OverlaySidebar> + {SECTIONS.map(value => ( + <OverlayNavItem + active={section === value} + icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3} + key={value} + label={SECTION_LABELS[value]} + onClick={() => setSection(value)} + /> + ))} + </OverlaySidebar> + + <OverlayMain> + <header className="mb-4 flex items-center justify-between gap-2"> + <div> + <h2 className="text-sm font-semibold text-foreground">{SECTION_LABELS[section]}</h2> + <p className="text-xs text-muted-foreground">{SECTION_DESCRIPTIONS[section]}</p> + </div> + {section === 'system' && ( + <OverlayActionButton disabled={systemLoading} onClick={() => void refreshSystem()}> + <IconRefresh className={cn('mr-1.5 size-3.5', systemLoading && 'animate-spin')} /> + {systemLoading ? 'Refreshing...' : 'Refresh'} + </OverlayActionButton> + )} + {section === 'usage' && ( + <OverlayActionButton disabled={usageLoading} onClick={() => void refreshUsage(usagePeriod)}> + <IconRefresh className={cn('mr-1.5 size-3.5', usageLoading && 'animate-spin')} /> + {usageLoading ? 'Refreshing...' : 'Refresh'} + </OverlayActionButton> + )} + {section === 'models' && ( + <OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}> + <IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} /> + {modelsLoading ? 'Refreshing...' : 'Refresh'} + </OverlayActionButton> + )} + </header> + + {showGlobalSearchResults ? ( + <div className="min-h-0 flex-1 overflow-y-auto pr-1"> + {!hasGlobalSearchResults ? ( + <OverlayCard className="px-3 py-4 text-sm text-muted-foreground"> + No matching results found. + </OverlayCard> + ) : ( + <div className="grid gap-3"> + {searchGroups.map(group => ( + <section className="grid gap-1.5" key={group.id}> + <h3 className="px-0.5 text-xs font-semibold tracking-[0.08em] text-muted-foreground/80 uppercase"> + {group.label} + </h3> + {group.results.map(result => { + if (result.kind === 'session') { + const pinned = pinnedSessionIds.includes(result.sessionId) + + return ( + <OverlayCard className="p-2.5" key={`${group.id}:${result.sessionId}:${result.snippet}`}> + <button + className="w-full text-left" + onClick={() => handleSearchSelect(result)} + type="button" + > + <div className="truncate text-sm font-medium text-foreground">{result.title}</div> + <div className="mt-0.5 text-xs text-muted-foreground"> + {result.detail || result.sessionId} + </div> + {result.snippet && ( + <div className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground/85"> + {result.snippet} + </div> + )} + </button> + <div className="mt-2 flex gap-1"> + <OverlayIconButton + onClick={event => { + event.preventDefault() + event.stopPropagation() + pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId) + }} + title={pinned ? 'Unpin session' : 'Pin session'} + > + {pinned ? ( + <IconBookmarkFilled className="size-3.5" /> + ) : ( + <IconBookmark className="size-3.5" /> + )} + </OverlayIconButton> + <OverlayIconButton + onClick={event => { + event.preventDefault() + event.stopPropagation() + void exportSession(result.sessionId, { title: result.title }) + }} + title="Export session" + > + <IconDownload className="size-3.5" /> + </OverlayIconButton> + <OverlayIconButton + className="hover:text-destructive" + onClick={event => { + event.preventDefault() + event.stopPropagation() + void onDeleteSession(result.sessionId) + }} + title="Delete session" + > + <IconTrash className="size-3.5" /> + </OverlayIconButton> + </div> + </OverlayCard> + ) + } + + return ( + <button + className={cn( + overlayCardClass, + 'w-full px-3 py-2 text-left transition-colors hover:bg-[color-mix(in_srgb,var(--dt-muted)_48%,var(--dt-card))]' + )} + key={`${group.id}:${result.kind}:${result.title}`} + onClick={() => handleSearchSelect(result)} + type="button" + > + <div className="text-sm font-medium text-foreground">{result.title}</div> + {result.detail && ( + <div className="mt-0.5 text-xs text-muted-foreground">{result.detail}</div> + )} + </button> + ) + })} + </section> + ))} + </div> + )} + </div> + ) : section === 'sessions' ? ( + <div className="min-h-0 flex-1 overflow-y-auto"> + {!sessionListHasResults ? ( + <OverlayCard className="px-3 py-4 text-sm text-muted-foreground">No sessions yet.</OverlayCard> + ) : ( + <div className="grid gap-1.5"> + {filteredSessions.map(session => { + const pinned = pinnedSessionIds.includes(session.id) + + return ( + <OverlayCard className="flex items-center gap-2 px-2.5 py-2" key={session.id}> + <button + className="min-w-0 flex-1 text-left" + onClick={() => onOpenSession(session.id)} + type="button" + > + <div className="truncate text-sm font-medium text-foreground">{sessionTitle(session)}</div> + <div className="truncate text-xs text-muted-foreground"> + {formatTimestamp(session.last_active || session.started_at)} + </div> + </button> + <OverlayIconButton + onClick={() => (pinned ? unpinSession(session.id) : pinSession(session.id))} + title={pinned ? 'Unpin session' : 'Pin session'} + > + {pinned ? <IconBookmarkFilled className="size-3.5" /> : <IconBookmark className="size-3.5" />} + </OverlayIconButton> + <OverlayIconButton + onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })} + title="Export session" + > + <IconDownload className="size-3.5" /> + </OverlayIconButton> + <OverlayIconButton + className="hover:text-destructive" + onClick={() => void onDeleteSession(session.id)} + title="Delete session" + > + <IconTrash className="size-3.5" /> + </OverlayIconButton> + </OverlayCard> + ) + })} + </div> + )} + </div> + ) : section === 'usage' ? ( + <UsagePanel + error={usageError} + loading={usageLoading} + onPeriodChange={setUsagePeriod} + onRefresh={() => void refreshUsage(usagePeriod)} + period={usagePeriod} + usage={usage} + /> + ) : section === 'system' ? ( + <div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3"> + <OverlayCard className="p-3 text-sm"> + {status ? ( + <div className="grid gap-2"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span + className={cn( + 'size-2 rounded-full', + status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500' + )} + /> + <span className="font-medium text-foreground"> + {status.gateway_running ? 'Messaging gateway running' : 'Messaging gateway stopped'} + </span> + </div> + <div className="mt-1 text-xs text-muted-foreground"> + Hermes {status.version} · Active sessions {status.active_sessions} + </div> + </div> + <div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap"> + <OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('restart')}> + Restart messaging + </OverlayActionButton> + <OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('update')}> + Update Hermes + </OverlayActionButton> + </div> + </div> + {systemAction && ( + <div className="text-xs text-muted-foreground"> + {systemAction.name} ·{' '} + {systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'} + </div> + )} + </div> + ) : ( + <div className="text-xs text-muted-foreground">Loading status...</div> + )} + </OverlayCard> + + <OverlayCard className="min-h-0 overflow-hidden p-2"> + <div className="mb-2 flex items-center justify-between"> + <span className="text-xs font-medium text-muted-foreground">Recent logs</span> + {systemError && ( + <span className="inline-flex items-center gap-1 text-xs text-destructive"> + <AlertCircle className="size-3.5" /> + {systemError} + </span> + )} + </div> + <pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.65rem] leading-relaxed text-muted-foreground"> + {logs.length ? logs.join('\n') : 'No logs loaded yet.'} + </pre> + </OverlayCard> + </div> + ) : ( + <div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3"> + <OverlayCard className="p-3"> + {mainModel ? ( + <> + <div className="text-sm font-medium text-foreground">Main model</div> + <div className="text-xs text-muted-foreground"> + {mainModel.provider} / {mainModel.model} + </div> + </> + ) : ( + <div className="text-xs text-muted-foreground">Loading model state...</div> + )} + </OverlayCard> + + <OverlayCard className="p-3"> + <div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div> + <div className="flex flex-wrap items-center gap-2"> + <select + className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground" + onChange={event => setSelectedProvider(event.target.value)} + value={selectedProvider} + > + {(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => ( + <option key={provider.slug || 'none'} value={provider.slug}> + {provider.name} + </option> + ))} + </select> + <select + className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground" + onChange={event => setSelectedModel(event.target.value)} + value={selectedModel} + > + {(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => ( + <option key={model || 'none'} value={model}> + {model || 'No models available'} + </option> + ))} + </select> + <OverlayActionButton + disabled={!selectedProvider || !selectedModel || applyingModel} + onClick={() => void applyMainModel()} + > + {applyingModel ? ( + <IconLoader2 className="mr-1.5 size-3.5 animate-spin" /> + ) : ( + <IconSparkles className="mr-1.5 size-3.5" /> + )} + {applyingModel ? 'Applying...' : 'Apply'} + </OverlayActionButton> + </div> + {modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>} + </OverlayCard> + + <OverlayCard className="min-h-0 overflow-auto p-2"> + <div className="mb-2 flex items-center justify-between"> + <span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span> + <OverlayActionButton + disabled={!mainModel || applyingModel} + onClick={() => void resetAuxiliaryModels()} + tone="subtle" + > + Reset all + </OverlayActionButton> + </div> + <div className="grid gap-1.5"> + {AUX_TASKS.map(meta => { + const current = auxiliary?.tasks.find(entry => entry.task === meta.key) + const isAuto = !current || !current.provider || current.provider === 'auto' + const isEditing = editingAuxTask === meta.key + + return ( + <OverlayCard className="px-2 py-1.5" key={meta.key}> + <div className="flex items-center gap-2"> + <div className="min-w-0 flex-1"> + <div className="flex items-baseline gap-2"> + <span className="text-xs font-medium text-foreground">{meta.label}</span> + <span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span> + </div> + <div className="truncate font-mono text-[0.62rem] text-muted-foreground"> + {isAuto + ? 'auto · use main model' + : `${current.provider} · ${current.model || '(provider default)'}`} + </div> + </div> + {!isEditing && ( + <> + <OverlayActionButton + disabled={!mainModel || applyingModel} + onClick={() => void setAuxiliaryToMain(meta.key)} + tone="subtle" + > + Set to main + </OverlayActionButton> + <OverlayActionButton + disabled={!providers.length || applyingModel} + onClick={() => beginAuxiliaryEdit(meta.key)} + > + Change + </OverlayActionButton> + </> + )} + </div> + + {isEditing && ( + <div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2"> + <select + className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground" + onChange={event => + setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' })) + } + value={auxDraft.provider} + > + {(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => ( + <option key={provider.slug || 'none'} value={provider.slug}> + {provider.name} + </option> + ))} + </select> + <select + className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground" + onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))} + value={auxDraft.model} + > + {(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => ( + <option key={model || 'none'} value={model}> + {model || 'No models available'} + </option> + ))} + </select> + <OverlayActionButton + disabled={!auxDraft.provider || !auxDraft.model || applyingModel} + onClick={() => void applyAuxiliaryDraft(meta.key)} + > + {applyingModel ? 'Applying...' : 'Apply'} + </OverlayActionButton> + <OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle"> + Cancel + </OverlayActionButton> + </div> + )} + </OverlayCard> + ) + })} + </div> + </OverlayCard> + </div> + )} + </OverlayMain> + </OverlaySplitLayout> + </OverlayView> + ) +} + +function formatTokens(value: null | number | undefined): string { + const num = Number(value || 0) + + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M` + } + + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K` + } + + return num.toLocaleString() +} + +function formatCost(value: null | number | undefined): string { + const num = Number(value || 0) + + if (num === 0) { + return '$0.00' + } + + if (num < 0.01) { + return '<$0.01' + } + + return `$${num.toFixed(2)}` +} + +function formatInteger(value: null | number | undefined): string { + return Number(value ?? 0).toLocaleString() +} + +interface UsagePanelProps { + error: string + loading: boolean + onPeriodChange: (period: UsagePeriod) => void + onRefresh: () => void + period: UsagePeriod + usage: AnalyticsResponse | null +} + +function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }: UsagePanelProps) { + const daily = useMemo(() => usage?.daily ?? [], [usage]) + const totals = usage?.totals + const byModel = usage?.by_model ?? [] + const topSkills = usage?.skills?.top_skills ?? [] + + const maxTokens = useMemo(() => { + if (!daily.length) { + return 1 + } + + return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1) + }, [daily]) + + return ( + <div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3"> + <OverlayCard className="flex flex-wrap items-center justify-between gap-2 p-3"> + <div className="flex items-center gap-1"> + {USAGE_PERIODS.map(value => ( + <button + className={cn( + 'h-7 rounded-md px-2.5 text-xs transition-colors', + value === period + ? 'bg-foreground text-background' + : 'text-muted-foreground hover:bg-muted/40 hover:text-foreground' + )} + key={value} + onClick={() => onPeriodChange(value)} + type="button" + > + {value}d + </button> + ))} + </div> + {error && ( + <span className="inline-flex items-center gap-1 text-xs text-destructive"> + <AlertCircle className="size-3.5" /> + {error} + </span> + )} + </OverlayCard> + + <OverlayCard className="p-3"> + {totals ? ( + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> + <UsageStat label="Sessions" value={formatInteger(totals.total_sessions)} /> + <UsageStat label="API calls" value={formatInteger(totals.total_api_calls)} /> + <UsageStat + label="Tokens in/out" + value={`${formatTokens(totals.total_input)} / ${formatTokens(totals.total_output)}`} + /> + <UsageStat + hint={totals.total_actual_cost > 0 ? `actual ${formatCost(totals.total_actual_cost)}` : undefined} + label="Est. cost" + value={formatCost(totals.total_estimated_cost)} + /> + </div> + ) : loading ? ( + <div className="text-xs text-muted-foreground">Loading usage...</div> + ) : ( + <div className="text-xs text-muted-foreground"> + No usage in the last {period} days.{' '} + <button className="underline underline-offset-4 decoration-current/20" onClick={onRefresh} type="button"> + Retry + </button> + </div> + )} + </OverlayCard> + + <div className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3"> + <OverlayCard className="p-3"> + <div className="mb-2 flex items-baseline justify-between"> + <span className="text-xs font-medium text-muted-foreground">Daily tokens</span> + <span className="flex items-center gap-3 text-[0.65rem] text-muted-foreground"> + <span className="inline-flex items-center gap-1"> + <span className="size-2 bg-[color:var(--dt-primary)]/60" /> input + </span> + <span className="inline-flex items-center gap-1"> + <span className="size-2 bg-emerald-500/70" /> output + </span> + </span> + </div> + {daily.length === 0 ? ( + <div className="grid h-24 place-items-center text-xs text-muted-foreground">No daily activity.</div> + ) : ( + <> + <div className="flex h-24 items-end gap-px"> + {daily.map(entry => { + const total = (entry.input_tokens || 0) + (entry.output_tokens || 0) + const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) + const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) + + return ( + <div + className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end" + key={entry.day} + title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`} + > + <div + className="w-full bg-[color:var(--dt-primary)]/50" + style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }} + /> + <div + className="w-full bg-emerald-500/60" + style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }} + /> + </div> + ) + })} + </div> + <div className="mt-1 flex justify-between text-[0.6rem] text-muted-foreground/70"> + <span>{daily[0]?.day}</span> + <span>{daily[daily.length - 1]?.day}</span> + </div> + </> + )} + </OverlayCard> + + <OverlayCard className="min-h-0 overflow-auto p-2"> + <div className="grid gap-3 sm:grid-cols-2"> + <section className="min-w-0"> + <div className="mb-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground"> + Top models + </div> + {byModel.length === 0 ? ( + <div className="text-xs text-muted-foreground">No model usage yet.</div> + ) : ( + <ul className="space-y-1"> + {byModel.slice(0, 6).map(entry => ( + <li + className="flex items-center justify-between gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40" + key={entry.model} + > + <span className="min-w-0 truncate font-mono text-[0.7rem] text-foreground">{entry.model}</span> + <span className="shrink-0 text-[0.65rem] text-muted-foreground"> + {formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} ·{' '} + {formatCost(entry.estimated_cost)} + </span> + </li> + ))} + </ul> + )} + </section> + + <section className="min-w-0"> + <div className="mb-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground"> + Top skills + </div> + {topSkills.length === 0 ? ( + <div className="text-xs text-muted-foreground">No skill activity yet.</div> + ) : ( + <ul className="space-y-1"> + {topSkills.slice(0, 6).map(entry => ( + <li + className="flex items-center justify-between gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40" + key={entry.skill} + > + <span className="min-w-0 truncate font-mono text-[0.7rem] text-foreground">{entry.skill}</span> + <span className="shrink-0 text-[0.65rem] text-muted-foreground"> + {entry.total_count.toLocaleString()} actions + </span> + </li> + ))} + </ul> + )} + </section> + </div> + </OverlayCard> + </div> + </div> + ) +} + +function UsageStat({ hint, label, value }: { hint?: string; label: string; value: string }) { + return ( + <div className="min-w-0"> + <div className="text-[0.65rem] font-medium uppercase tracking-[0.12em] text-muted-foreground">{label}</div> + <div className="mt-0.5 truncate text-sm font-semibold tracking-tight text-foreground">{value}</div> + {hint && <div className="mt-0.5 truncate text-[0.62rem] text-muted-foreground/80">{hint}</div>} + </div> + ) +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx new file mode 100644 index 000000000..b5c1deb08 --- /dev/null +++ b/apps/desktop/src/app/cron/index.tsx @@ -0,0 +1,874 @@ +import type * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { + createCronJob, + type CronJob, + deleteCronJob, + getCronJobs, + pauseCronJob, + resumeCronJob, + triggerCronJob, + updateCronJob +} from '@/hermes' +import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' + +import { PageSearchShell } from '../page-search-shell' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' + +const DEFAULT_DELIVER = 'local' + +const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ + { label: 'This desktop', value: 'local' }, + { label: 'Telegram', value: 'telegram' }, + { label: 'Discord', value: 'discord' }, + { label: 'Slack', value: 'slack' }, + { label: 'Email', value: 'email' } +] + +const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [ + { + expr: '0 9 * * *', + hint: 'Every day at 9:00 AM', + label: 'Daily', + value: 'daily' + }, + { + expr: '0 9 * * 1-5', + hint: 'Monday through Friday at 9:00 AM', + label: 'Weekdays', + value: 'weekdays' + }, + { + expr: '0 9 * * 1', + hint: 'Every Monday at 9:00 AM', + label: 'Weekly', + value: 'weekly' + }, + { + expr: '0 9 1 * *', + hint: 'The first day of each month at 9:00 AM', + label: 'Monthly', + value: 'monthly' + }, + { + expr: '0 * * * *', + hint: 'At the top of every hour', + label: 'Hourly', + value: 'hourly' + }, + { + expr: '*/15 * * * *', + hint: 'Every 15 minutes', + label: 'Every 15 minutes', + value: 'every-15-minutes' + }, + { + hint: 'Cron syntax or natural language', + label: 'Custom', + value: 'custom' + } +] + +const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = { + enabled: 'good', + scheduled: 'good', + running: 'good', + paused: 'warn', + disabled: 'muted', + error: 'bad', + completed: 'muted' +} + +const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = { + good: 'bg-primary/10 text-primary', + muted: 'bg-muted text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', + bad: 'bg-destructive/10 text-destructive' +} + +const asText = (value: unknown): string => (typeof value === 'string' ? value : '') + +const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value) + +function jobName(job: CronJob): string { + return asText(job.name).trim() +} + +function jobPrompt(job: CronJob): string { + return asText(job.prompt) +} + +function jobTitle(job: CronJob): string { + const name = jobName(job) + + if (name) { + return name + } + + const prompt = jobPrompt(job) + + if (prompt) { + return truncate(prompt, 60) + } + + const script = asText(job.script) + + if (script) { + return truncate(script, 60) + } + + return job.id || 'Cron job' +} + +function jobScheduleDisplay(job: CronJob): string { + return asText(job.schedule_display) || asText(job.schedule?.display) || asText(job.schedule?.expr) || '—' +} + +function jobScheduleExpr(job: CronJob): string { + return asText(job.schedule?.expr) || asText(job.schedule_display) || '' +} + +function jobState(job: CronJob): string { + return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled') +} + +function jobDeliver(job: CronJob): string { + return asText(job.deliver) || DEFAULT_DELIVER +} + +function cronParts(expr: string): null | string[] { + const parts = expr.trim().replace(/\s+/g, ' ').split(' ') + + return parts.length === 5 ? parts : null +} + +function dayName(value: string): string { + const names: Record<string, string> = { + '0': 'Sunday', + '1': 'Monday', + '2': 'Tuesday', + '3': 'Wednesday', + '4': 'Thursday', + '5': 'Friday', + '6': 'Saturday', + '7': 'Sunday' + } + + return names[value] ?? `day ${value}` +} + +function formatCronTime(minute: string, hour: string): string { + const numericHour = Number(hour) + const numericMinute = Number(minute) + + if (!Number.isInteger(numericHour) || !Number.isInteger(numericMinute)) { + return `${hour}:${minute}` + } + + return new Date(2000, 0, 1, numericHour, numericMinute).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit' + }) +} + +function isIntegerToken(value: string): boolean { + return /^\d+$/.test(value) +} + +function scheduleOptionForExpr(expr: string): ScheduleOption { + const normalized = expr.trim().replace(/\s+/g, ' ') + const exactMatch = SCHEDULE_OPTIONS.find(option => option.expr === normalized) + + if (exactMatch) { + return exactMatch + } + + const parts = cronParts(normalized) + + if (!parts) { + return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1] + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + + if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*' && isIntegerToken(minute) && isIntegerToken(hour)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'daily') ?? SCHEDULE_OPTIONS[0] + } + + if (dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5' && isIntegerToken(minute) && isIntegerToken(hour)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'weekdays') ?? SCHEDULE_OPTIONS[0] + } + + if ( + dayOfMonth === '*' && + month === '*' && + isIntegerToken(dayOfWeek) && + isIntegerToken(minute) && + isIntegerToken(hour) + ) { + return SCHEDULE_OPTIONS.find(option => option.value === 'weekly') ?? SCHEDULE_OPTIONS[0] + } + + if ( + month === '*' && + dayOfWeek === '*' && + isIntegerToken(dayOfMonth) && + isIntegerToken(minute) && + isIntegerToken(hour) + ) { + return SCHEDULE_OPTIONS.find(option => option.value === 'monthly') ?? SCHEDULE_OPTIONS[0] + } + + if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*' && isIntegerToken(minute)) { + return SCHEDULE_OPTIONS.find(option => option.value === 'hourly') ?? SCHEDULE_OPTIONS[0] + } + + if (normalized === '*/15 * * * *') { + return SCHEDULE_OPTIONS.find(option => option.value === 'every-15-minutes') ?? SCHEDULE_OPTIONS[0] + } + + return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1] +} + +function scheduleSummary(option: ScheduleOption, expr: string): string { + const parts = cronParts(expr) + + if (!parts) { + return option.hint + } + + const [minute, hour, dayOfMonth, , dayOfWeek] = parts + + if (option.value === 'daily') { + return `Every day at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'weekdays') { + return `Weekdays at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'weekly') { + return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'monthly') { + return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}` + } + + if (option.value === 'hourly') { + return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}` + } + + return option.hint +} + +function formatTime(iso?: null | string): string { + if (!iso) { + return '—' + } + + const date = new Date(iso) + + if (Number.isNaN(date.valueOf())) { + return iso + } + + return date.toLocaleString() +} + +function matchesQuery(job: CronJob, q: string): boolean { + if (!q) { + return true + } + + const needle = q.toLowerCase() + + return [jobTitle(job), jobPrompt(job), jobScheduleDisplay(job), jobScheduleExpr(job), jobDeliver(job)].some(value => + value.toLowerCase().includes(needle) + ) +} + +interface CronViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup +} + +export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) { + const [jobs, setJobs] = useState<CronJob[] | null>(null) + const [query, setQuery] = useState('') + const [refreshing, setRefreshing] = useState(false) + const [busyJobId, setBusyJobId] = useState<null | string>(null) + + const [editor, setEditor] = useState<EditorState>({ mode: 'closed' }) + const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null) + const [deleting, setDeleting] = useState(false) + + const refresh = useCallback(async () => { + setRefreshing(true) + + try { + const result = await getCronJobs() + setJobs(result) + } catch (err) { + notifyError(err, 'Failed to load cron jobs') + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const visibleJobs = useMemo(() => { + if (!jobs) { + return [] + } + + return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))) + }, [jobs, query]) + + const enabledCount = jobs?.filter(job => job.enabled).length ?? 0 + const totalCount = jobs?.length ?? 0 + + async function handlePauseResume(job: CronJob) { + setBusyJobId(job.id) + + try { + const isPaused = jobState(job) === 'paused' + const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id) + setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) + notify({ + kind: 'success', + title: isPaused ? 'Cron resumed' : 'Cron paused', + message: truncate(jobTitle(job), 60) + }) + } catch (err) { + notifyError(err, 'Failed to update cron job') + } finally { + setBusyJobId(null) + } + } + + async function handleTrigger(job: CronJob) { + setBusyJobId(job.id) + + try { + const updated = await triggerCronJob(job.id) + setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) + notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) }) + } catch (err) { + notifyError(err, 'Failed to trigger cron job') + } finally { + setBusyJobId(null) + } + } + + async function handleConfirmDelete() { + if (!pendingDelete) { + return + } + + setDeleting(true) + + try { + await deleteCronJob(pendingDelete.id) + setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current)) + notify({ kind: 'success', title: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) }) + setPendingDelete(null) + } catch (err) { + notifyError(err, 'Failed to delete cron job') + } finally { + setDeleting(false) + } + } + + async function handleEditorSave(values: EditorValues) { + if (editor.mode === 'create') { + const created = await createCronJob({ + prompt: values.prompt, + schedule: values.schedule, + name: values.name || undefined, + deliver: values.deliver || DEFAULT_DELIVER + }) + + setJobs(current => (current ? [...current, created] : [created])) + notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) }) + } else if (editor.mode === 'edit') { + const updated = await updateCronJob(editor.job.id, { + prompt: values.prompt, + schedule: values.schedule, + name: values.name, + deliver: values.deliver + }) + + setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current)) + notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) }) + } + + setEditor({ mode: 'closed' }) + } + + return ( + <PageSearchShell + {...props} + filters={ + <div className="flex flex-wrap items-center justify-center gap-2"> + <Button onClick={() => setEditor({ mode: 'create' })} size="sm"> + <Codicon name="add" /> + New cron + </Button> + </div> + } + onSearchChange={setQuery} + searchPlaceholder="Search cron jobs..." + searchTrailingAction={ + <Button + aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'} + className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground" + disabled={refreshing} + onClick={() => void refresh()} + size="icon-xs" + title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'} + type="button" + variant="ghost" + > + <Codicon name="refresh" size="0.875rem" spinning={refreshing} /> + </Button> + } + searchValue={query} + > + {!jobs ? ( + <PageLoader label="Loading cron jobs..." /> + ) : visibleJobs.length === 0 ? ( + <EmptyState + actionLabel={totalCount === 0 ? 'Create first cron' : undefined} + description={ + totalCount === 0 + ? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.' + : 'Try a broader search query.' + } + onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined} + title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'} + /> + ) : ( + <div className="h-full overflow-y-auto px-4 py-3"> + <div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70"> + {visibleJobs.map(job => ( + <CronJobRow + busy={busyJobId === job.id} + job={job} + key={job.id} + onDelete={() => setPendingDelete(job)} + onEdit={() => setEditor({ mode: 'edit', job })} + onPauseResume={() => void handlePauseResume(job)} + onTrigger={() => void handleTrigger(job)} + /> + ))} + </div> + </div> + )} + <div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div> + + <CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> + + <Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Delete cron job?</DialogTitle> + <DialogDescription> + {pendingDelete ? ( + <> + This will remove{' '} + <span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '} + permanently. It will stop firing immediately. + </> + ) : null} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline"> + Cancel + </Button> + <Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive"> + {deleting ? 'Deleting...' : 'Delete'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </PageSearchShell> + ) +} + +function CronJobRow({ + busy, + job, + onDelete, + onEdit, + onPauseResume, + onTrigger +}: { + busy: boolean + job: CronJob + onDelete: () => void + onEdit: () => void + onPauseResume: () => void + onTrigger: () => void +}) { + const state = jobState(job) + const isPaused = state === 'paused' + const hasName = Boolean(jobName(job)) + const prompt = jobPrompt(job) + const deliver = jobDeliver(job) + + return ( + <div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"> + <button + className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40" + onClick={onEdit} + type="button" + > + <div className="flex flex-wrap items-center gap-2"> + <span className="truncate text-sm font-medium">{jobTitle(job)}</span> + <StatePill tone={STATE_TONE[state] ?? 'muted'}>{state}</StatePill> + {deliver && deliver !== DEFAULT_DELIVER && <StatePill tone="muted">{deliver}</StatePill>} + </div> + {hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>} + <div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground"> + <span className="inline-flex items-center gap-1 font-mono"> + <Clock className="size-3" /> + {jobScheduleDisplay(job)} + </span> + <span>Last: {formatTime(job.last_run_at)}</span> + <span>Next: {formatTime(job.next_run_at)}</span> + </div> + {job.last_error && ( + <p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive"> + <AlertTriangle className="mt-px size-3 shrink-0" /> + <span className="line-clamp-2">{job.last_error}</span> + </p> + )} + </button> + + <div className="flex shrink-0 items-center gap-0.5"> + <IconAction + aria-label={isPaused ? 'Resume cron' : 'Pause cron'} + disabled={busy} + onClick={onPauseResume} + title={isPaused ? 'Resume' : 'Pause'} + > + {isPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />} + </IconAction> + <IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now"> + <Zap className="size-3.5" /> + </IconAction> + <IconAction aria-label="Edit cron" onClick={onEdit} title="Edit"> + <Pencil className="size-3.5" /> + </IconAction> + <IconAction + aria-label="Delete cron" + className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive" + onClick={onDelete} + title="Delete" + > + <Trash2 className="size-3.5" /> + </IconAction> + </div> + </div> + ) +} + +function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) { + return ( + <Button + className={cn('size-7 text-muted-foreground hover:text-foreground', className)} + size="icon" + variant="ghost" + {...props} + > + {children} + </Button> + ) +} + +function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { + return ( + <span + className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])} + > + {children} + </span> + ) +} + +function EmptyState({ + actionLabel, + description, + onAction, + title +}: { + actionLabel?: string + description: string + onAction?: () => void + title: string +}) { + return ( + <div className="grid h-full place-items-center px-6 py-12 text-center"> + <div className="max-w-sm space-y-2"> + <div className="text-sm font-medium">{title}</div> + <p className="text-xs text-muted-foreground">{description}</p> + {actionLabel && onAction && ( + <Button className="mt-2" onClick={onAction} size="sm"> + <Codicon name="add" /> + {actionLabel} + </Button> + )} + </div> + </div> + ) +} + +function CronEditorDialog({ + editor, + onClose, + onSave +}: { + editor: EditorState + onClose: () => void + onSave: (values: EditorValues) => Promise<void> +}) { + const open = editor.mode !== 'closed' + const isEdit = editor.mode === 'edit' + const initial = isEdit ? editor.job : null + + const [name, setName] = useState('') + const [prompt, setPrompt] = useState('') + const [schedule, setSchedule] = useState('') + const [schedulePreset, setSchedulePreset] = useState('daily') + const [deliver, setDeliver] = useState(DEFAULT_DELIVER) + const [saving, setSaving] = useState(false) + const [error, setError] = useState<null | string>(null) + + useEffect(() => { + if (!open) { + return + } + + setName(initial ? jobName(initial) : '') + setPrompt(initial ? jobPrompt(initial) : '') + setSchedule(initial ? jobScheduleExpr(initial) : (SCHEDULE_OPTIONS[0].expr ?? '')) + setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily') + setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER) + setError(null) + setSaving(false) + }, [initial, open]) + + const selectedScheduleOption = + SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0] + + function handleSchedulePresetChange(nextPreset: string) { + setSchedulePreset(nextPreset) + setError(null) + + const option = SCHEDULE_OPTIONS.find(candidate => candidate.value === nextPreset) + + if (option?.expr) { + setSchedule(option.expr) + } else if (scheduleOptionForExpr(schedule).value !== 'custom') { + setSchedule('') + } + } + + const scheduleHint = scheduleSummary(selectedScheduleOption, schedule) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const trimmedPrompt = prompt.trim() + const trimmedSchedule = schedule.trim() + + if (!trimmedPrompt || !trimmedSchedule) { + setError('Prompt and schedule are required.') + + return + } + + setSaving(true) + setError(null) + + try { + await onSave({ + deliver, + name: name.trim(), + prompt: trimmedPrompt, + schedule: trimmedSchedule + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save cron job') + } finally { + setSaving(false) + } + } + + return ( + <Dialog onOpenChange={value => !value && !saving && onClose()} open={open}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle> + <DialogDescription> + {isEdit + ? 'Update the schedule, prompt, or delivery target. Changes apply on next run.' + : 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'} + </DialogDescription> + </DialogHeader> + + <form className="grid gap-4" onSubmit={handleSubmit}> + <Field htmlFor="cron-name" label="Name" optional> + <Input + autoFocus + id="cron-name" + onChange={event => setName(event.target.value)} + placeholder="Morning briefing" + value={name} + /> + </Field> + + <Field htmlFor="cron-prompt" label="Prompt"> + <Textarea + className="min-h-24 font-mono" + id="cron-prompt" + onChange={event => setPrompt(event.target.value)} + placeholder="Summarize my unread Slack threads and email me the top 5..." + value={prompt} + /> + </Field> + + <div className="grid items-start gap-4 sm:grid-cols-2"> + <Field htmlFor="cron-frequency" label="Frequency"> + <Select onValueChange={handleSchedulePresetChange} value={schedulePreset}> + <SelectTrigger className="h-9 rounded-md" id="cron-frequency"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {SCHEDULE_OPTIONS.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </Field> + + <Field htmlFor="cron-deliver" label="Deliver to"> + <Select onValueChange={setDeliver} value={deliver}> + <SelectTrigger className="h-9 rounded-md" id="cron-deliver"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {DELIVERY_OPTIONS.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </Field> + </div> + + {schedulePreset === 'custom' ? ( + <Field htmlFor="cron-schedule" label="Custom schedule"> + <Input + className="font-mono" + id="cron-schedule" + onChange={event => setSchedule(event.target.value)} + placeholder="0 9 * * * or weekdays at 9am" + value={schedule} + /> + <FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint> + </Field> + ) : ( + <div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2"> + <div className="flex flex-wrap items-center justify-between gap-2 text-xs"> + <span className="font-medium text-foreground">{scheduleHint}</span> + <span className="font-mono text-muted-foreground">{schedule}</span> + </div> + </div> + )} + + {error && ( + <div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"> + <AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> + <span>{error}</span> + </div> + )} + + <DialogFooter> + <Button disabled={saving} onClick={onClose} type="button" variant="outline"> + Cancel + </Button> + <Button disabled={saving} type="submit"> + {saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +} + +function Field({ + children, + htmlFor, + label, + optional +}: { + children: React.ReactNode + htmlFor: string + label: string + optional?: boolean +}) { + return ( + <div className="grid gap-1.5"> + <label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}> + {label} + {optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>} + </label> + {children} + </div> + ) +} + +function FieldHint({ children }: { children: React.ReactNode }) { + return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p> +} + +type EditorState = { mode: 'closed' } | { mode: 'create' } | { job: CronJob; mode: 'edit' } + +interface EditorValues { + deliver: string + name: string + prompt: string + schedule: string +} + +interface ScheduleOption { + expr?: string + hint: string + label: string + value: string +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx new file mode 100644 index 000000000..d8dbc9a9c --- /dev/null +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -0,0 +1,680 @@ +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import { lazy, Suspense, useCallback, useEffect, useRef } from 'react' +import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' + +import { BootFailureOverlay } from '@/components/boot-failure-overlay' +import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' +import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' +import { Pane, PaneMain } from '@/components/pane-shell' +import { useSkinCommand } from '@/themes/use-skin-command' + +import { formatRefValue } from '../components/assistant-ui/directive-text' +import { getSessionMessages, listSessions } from '../hermes' +import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' +import { + $pinnedSessionIds, + $sessionsLimit, + bumpSessionsLimit, + FILE_BROWSER_DEFAULT_WIDTH, + FILE_BROWSER_MAX_WIDTH, + FILE_BROWSER_MIN_WIDTH, + pinSession, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MAX_WIDTH, + unpinSession +} from '../store/layout' +import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' +import { + $activeSessionId, + $currentCwd, + $freshDraftReady, + $gatewayState, + $selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setCurrentModel, + setCurrentProvider, + setMessages, + setSessions, + setSessionsLoading, + setSessionsTotal +} from '../store/session' +import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' + +import { ChatView } from './chat' +import { useComposerActions } from './chat/hooks/use-composer-actions' +import { + ChatPreviewRail, + PREVIEW_RAIL_MAX_WIDTH, + PREVIEW_RAIL_MIN_WIDTH, + PREVIEW_RAIL_PANE_WIDTH +} from './chat/right-rail' +import { ChatSidebar } from './chat/sidebar' +import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' +import { useGatewayRequest } from './gateway/hooks/use-gateway-request' +import { ModelPickerOverlay } from './model-picker-overlay' +import { RightSidebarPane } from './right-sidebar' +import { $terminalTakeover } from './right-sidebar/store' +import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' +import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes' +import { useContextSuggestions } from './session/hooks/use-context-suggestions' +import { useCwdActions } from './session/hooks/use-cwd-actions' +import { useHermesConfig } from './session/hooks/use-hermes-config' +import { useMessageStream } from './session/hooks/use-message-stream' +import { useModelControls } from './session/hooks/use-model-controls' +import { usePreviewRouting } from './session/hooks/use-preview-routing' +import { usePromptActions } from './session/hooks/use-prompt-actions' +import { useRouteResume } from './session/hooks/use-route-resume' +import { useSessionActions } from './session/hooks/use-session-actions' +import { useSessionStateCache } from './session/hooks/use-session-state-cache' +import { AppShell } from './shell/app-shell' +import { useOverlayRouting } from './shell/hooks/use-overlay-routing' +import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' +import { useStatusbarItems } from './shell/hooks/use-statusbar-items' +import type { StatusbarItem } from './shell/statusbar-controls' +import type { TitlebarTool } from './shell/titlebar-controls' +import { useGroupRegistry } from './shell/use-group-registry' +import { UpdatesOverlay } from './updates-overlay' + +const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView })) +const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) +const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) +const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) +const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) +const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) +const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) +const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) + +export function DesktopController() { + const queryClient = useQueryClient() + const location = useLocation() + const navigate = useNavigate() + + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + const refreshSessionsRequestRef = useRef(0) + + const gatewayState = useStore($gatewayState) + const activeSessionId = useStore($activeSessionId) + const currentCwd = useStore($currentCwd) + const freshDraftReady = useStore($freshDraftReady) + const filePreviewTarget = useStore($filePreviewTarget) + const previewTarget = useStore($previewTarget) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const terminalTakeover = useStore($terminalTakeover) + + const routedSessionId = routeSessionId(location.pathname) + const routeToken = `${location.pathname}:${location.search}:${location.hash}` + const routeTokenRef = useRef(routeToken) + routeTokenRef.current = routeToken + const getRouteToken = useCallback(() => routeTokenRef.current, []) + + const { + agentsOpen, + chatOpen, + closeOverlayToPreviousRoute, + commandCenterInitialSection, + commandCenterOpen, + currentView, + openAgents, + openCommandCenterSection, + settingsOpen, + toggleCommandCenter + } = useOverlayRouting() + const terminalTakeoverActive = chatOpen && terminalTakeover + + const titlebarToolGroups = useGroupRegistry<TitlebarTool>() + const statusbarItemGroups = useGroupRegistry<StatusbarItem>() + const setTitlebarToolGroup = titlebarToolGroups.set + const setStatusbarItemGroup = statusbarItemGroups.set + + const { + activeSessionIdRef, + ensureSessionState, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + } = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages + }) + + const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() + + useEffect(() => { + window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget))) + }, [chatOpen, filePreviewTarget, previewTarget]) + + useEffect(() => { + startUpdatePoller() + const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) + + return () => { + unsubscribe?.() + stopUpdatePoller() + } + }, []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!$filePreviewTarget.get() && !$previewTarget.get()) { + return + } + + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') { + event.preventDefault() + event.stopPropagation() + closeActiveRightRailTab() + } + } + + const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab) + + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => { + unsubscribe?.() + window.removeEventListener('keydown', onKeyDown, { capture: true }) + } + }, []) + + const refreshSessions = useCallback(async () => { + const requestId = refreshSessionsRequestRef.current + 1 + refreshSessionsRequestRef.current = requestId + setSessionsLoading(true) + + try { + const limit = $sessionsLimit.get() + const result = await listSessions(limit) + + if (refreshSessionsRequestRef.current === requestId) { + setSessions(result.sessions) + setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) + } + } finally { + if (refreshSessionsRequestRef.current === requestId) { + setSessionsLoading(false) + } + } + }, []) + + const loadMoreSessions = useCallback(() => { + bumpSessionsLimit() + void refreshSessions() + }, [refreshSessions]) + + const toggleSelectedPin = useCallback(() => { + const sessionId = $selectedStoredSessionId.get() + + if (!sessionId) { + return + } + + if ($pinnedSessionIds.get().includes(sessionId)) { + unpinSession(sessionId) + } else { + pinSession(sessionId) + } + }, []) + + const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) + + const updateActiveSessionRuntimeInfo = useCallback( + (info: { branch?: string; cwd?: string }) => { + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + return + } + + updateSessionState(sessionId, state => ({ + ...state, + branch: info.branch ?? state.branch, + cwd: info.cwd ?? state.cwd + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + const { changeSessionCwd, refreshProjectBranch } = useCwdActions({ + activeSessionId, + activeSessionIdRef, + onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, + requestGateway + }) + + const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ + activeSessionIdRef, + refreshProjectBranch + }) + + const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ + activeSessionId, + queryClient, + requestGateway + }) + + useContextSuggestions({ + activeSessionId, + activeSessionIdRef, + currentCwd, + gatewayState, + requestGateway + }) + + const hydrateFromStoredSession = useCallback( + async ( + attempts = 1, + storedSessionId = selectedStoredSessionIdRef.current, + runtimeSessionId = activeSessionIdRef.current + ) => { + if (!storedSessionId || !runtimeSessionId) { + return + } + + for (let index = 0; index < Math.max(1, attempts); index += 1) { + try { + const latest = await getSessionMessages(storedSessionId) + updateSessionState( + runtimeSessionId, + state => ({ + ...state, + messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages) + }), + storedSessionId + ) + + return + } catch { + // Best-effort fallback when live stream payloads are empty. + } + + if (index < attempts - 1) { + await new Promise(resolve => window.setTimeout(resolve, 250)) + } + } + }, + [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] + ) + + const { handleGatewayEvent } = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession, + queryClient, + refreshHermesConfig, + refreshSessions, + updateSessionState + }) + + const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ + activeSessionIdRef, + baseHandleGatewayEvent: handleGatewayEvent, + currentCwd, + currentView, + requestGateway, + routedSessionId, + selectedStoredSessionId + }) + + const { + branchCurrentSession, + createBackendSessionForSend, + openSettings, + removeSession, + resumeSession, + selectSidebarItem, + startFreshSessionDraft + } = useSessionActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + }) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + + const editing = + target?.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement + + if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) { + return + } + + if (event.shiftKey && event.code === 'KeyN') { + event.preventDefault() + startFreshSessionDraft() + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [startFreshSessionDraft]) + + const composer = useComposerActions({ + activeSessionId, + currentCwd, + requestGateway + }) + + const branchInNewChat = useCallback( + async (messageId?: string) => { + const branched = await branchCurrentSession(messageId) + + if (branched) { + await refreshSessions().catch(() => undefined) + } + + return branched + }, + [branchCurrentSession, refreshSessions] + ) + + const handleSkinCommand = useSkinCommand() + + const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } = + usePromptActions({ + activeSessionId, + activeSessionIdRef, + branchCurrentSession: branchInNewChat, + busyRef, + createBackendSessionForSend, + handleSkinCommand, + requestGateway, + selectedStoredSessionIdRef, + startFreshSessionDraft, + sttEnabled, + updateSessionState + }) + + useGatewayBoot({ + handleGatewayEvent: handleDesktopGatewayEvent, + onConnectionReady: c => { + connectionRef.current = c + }, + onGatewayReady: g => { + gatewayRef.current = g + }, + refreshHermesConfig, + refreshSessions + }) + + useEffect(() => { + if (gatewayState === 'open') { + void refreshCurrentModel() + void refreshSessions().catch(() => undefined) + } + }, [gatewayState, refreshCurrentModel, refreshSessions]) + + useRouteResume({ + activeSessionId, + activeSessionIdRef, + creatingSessionRef, + currentView, + freshDraftReady, + gatewayState, + locationPathname: location.pathname, + resumeSession, + routedSessionId, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft + }) + + const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ + agentsOpen, + commandCenterOpen, + extraLeftItems: statusbarItemGroups.flat.left, + extraRightItems: statusbarItemGroups.flat.right, + gatewayLogLines, + gatewayState, + inferenceStatus, + openAgents, + openCommandCenterSection, + statusSnapshot, + toggleCommandCenter + }) + + const sidebar = ( + <ChatSidebar + currentView={currentView} + onDeleteSession={sessionId => void removeSession(sessionId)} + onLoadMoreSessions={loadMoreSessions} + onNavigate={selectSidebarItem} + onResumeSession={sessionId => navigate(sessionRoute(sessionId))} + /> + ) + + const overlays = ( + <> + <DesktopInstallOverlay /> + {/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders + decide where it shows. Toggling fullscreen never rebuilds the shell. */} + <PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} /> + <DesktopOnboardingOverlay + enabled={gatewayState === 'open'} + onCompleted={() => { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + requestGateway={requestGateway} + /> + <ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} /> + <UpdatesOverlay /> + <BootFailureOverlay /> + + {settingsOpen && ( + <Suspense fallback={null}> + <SettingsView + gateway={gatewayRef.current} + onClose={closeOverlayToPreviousRoute} + onConfigSaved={() => { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + /> + </Suspense> + )} + + {commandCenterOpen && ( + <Suspense fallback={null}> + <CommandCenterView + initialSection={commandCenterInitialSection} + onClose={closeOverlayToPreviousRoute} + onDeleteSession={removeSession} + onMainModelChanged={(provider, model) => { + setCurrentProvider(provider) + setCurrentModel(model) + updateModelOptionsCache(provider, model, true) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + onNavigateRoute={path => navigate(path)} + onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + /> + </Suspense> + )} + + {agentsOpen && ( + <Suspense fallback={null}> + <AgentsView onClose={closeOverlayToPreviousRoute} /> + </Suspense> + )} + </> + ) + + const chatView = ( + <ChatView + gateway={gatewayRef.current} + maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} + onAddContextRef={composer.addContextRefAttachment} + onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} + onAttachDroppedItems={composer.attachDroppedItems} + onAttachImageBlob={composer.attachImageBlob} + onBranchInNewChat={messageId => void branchInNewChat(messageId)} + onCancel={cancelRun} + onDeleteSelectedSession={() => { + if (selectedStoredSessionId) { + void removeSession(selectedStoredSessionId) + } + }} + onEdit={editMessage} + onPasteClipboardImage={() => void composer.pasteClipboardImage()} + onPickFiles={() => void composer.pickContextPaths('file')} + onPickFolders={() => void composer.pickContextPaths('folder')} + onPickImages={() => void composer.pickImages()} + onReload={reloadFromMessage} + onRemoveAttachment={id => void composer.removeAttachment(id)} + onSubmit={submitText} + onThreadMessagesChange={handleThreadMessagesChange} + onToggleSelectedPin={toggleSelectedPin} + onTranscribeAudio={transcribeVoiceAudio} + /> + ) + + const takeoverTerminalView = ( + <div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)"> + <TerminalSlot /> + </div> + ) + + return ( + <AppShell + commandCenterOpen={commandCenterOpen} + leftStatusbarItems={leftStatusbarItems} + leftTitlebarTools={titlebarToolGroups.flat.left} + onOpenSearch={() => openCommandCenterSection('sessions')} + onOpenSettings={openSettings} + overlays={overlays} + statusbarItems={statusbarItems} + titlebarTools={titlebarToolGroups.flat.right} + > + <Pane + id="chat-sidebar" + maxWidth={SIDEBAR_MAX_WIDTH} + minWidth={SIDEBAR_DEFAULT_WIDTH} + disabled={terminalTakeoverActive} + resizable + side="left" + width={`${SIDEBAR_DEFAULT_WIDTH}px`} + > + {sidebar} + </Pane> + <PaneMain> + <Routes> + <Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index /> + <Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" /> + <Route + element={ + <Suspense fallback={null}> + <SkillsView setStatusbarItemGroup={setStatusbarItemGroup} /> + </Suspense> + } + path="skills" + /> + <Route + element={ + <Suspense fallback={null}> + <MessagingView setStatusbarItemGroup={setStatusbarItemGroup} /> + </Suspense> + } + path="messaging" + /> + <Route + element={ + <Suspense fallback={null}> + <ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} /> + </Suspense> + } + path="artifacts" + /> + <Route + element={ + <Suspense fallback={null}> + <CronView setStatusbarItemGroup={setStatusbarItemGroup} /> + </Suspense> + } + path="cron" + /> + <Route + element={ + <Suspense fallback={null}> + <ProfilesView + setStatusbarItemGroup={setStatusbarItemGroup} + setTitlebarToolGroup={setTitlebarToolGroup} + /> + </Suspense> + } + path="profiles" + /> + <Route element={null} path="settings" /> + <Route element={null} path="command-center" /> + <Route element={null} path="agents" /> + <Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" /> + <Route element={<LegacySessionRedirect />} path="sessions/:sessionId" /> + <Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" /> + </Routes> + </PaneMain> + <Pane + disabled={!chatOpen || (!previewTarget && !filePreviewTarget)} + id="preview" + maxWidth={PREVIEW_RAIL_MAX_WIDTH} + minWidth={PREVIEW_RAIL_MIN_WIDTH} + resizable + side="right" + width={PREVIEW_RAIL_PANE_WIDTH} + > + {chatOpen ? ( + <ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} /> + ) : null} + </Pane> + <Pane + defaultOpen={false} + disabled={!chatOpen} + id="file-browser" + maxWidth={FILE_BROWSER_MAX_WIDTH} + minWidth={FILE_BROWSER_MIN_WIDTH} + resizable + side="right" + width={FILE_BROWSER_DEFAULT_WIDTH} + > + <RightSidebarPane + onActivateFile={composer.attachContextFilePath} + onActivateFolder={composer.attachContextFolderPath} + onChangeCwd={changeSessionCwd} + /> + </Pane> + </AppShell> + ) +} + +function LegacySessionRedirect() { + const { sessionId } = useParams() + + return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} /> +} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts new file mode 100644 index 000000000..7904128eb --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -0,0 +1,169 @@ +import { useEffect, useRef } from 'react' + +import type { HermesConnection } from '@/global' +import { HermesGateway } from '@/hermes' +import { + $desktopBoot, + applyDesktopBootProgress, + completeDesktopBoot, + failDesktopBoot, + setDesktopBootStep +} from '@/store/boot' +import { setGateway } from '@/store/gateway' +import { notify, notifyError } from '@/store/notifications' +import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session' +import type { RpcEvent } from '@/types/hermes' + +interface GatewayBootOptions { + handleGatewayEvent: (event: RpcEvent) => void + onConnectionReady: ( + connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null + ) => void + onGatewayReady: (gateway: HermesGateway | null) => void + refreshHermesConfig: () => Promise<void> + refreshSessions: () => Promise<void> +} + +export function useGatewayBoot({ + handleGatewayEvent, + onConnectionReady, + onGatewayReady, + refreshHermesConfig, + refreshSessions +}: GatewayBootOptions) { + const callbacksRef = useRef({ + handleGatewayEvent, + onConnectionReady, + onGatewayReady, + refreshHermesConfig, + refreshSessions + }) + + callbacksRef.current = { + handleGatewayEvent, + onConnectionReady, + onGatewayReady, + refreshHermesConfig, + refreshSessions + } + + useEffect(() => { + let cancelled = false + const desktop = window.hermesDesktop + + const publish = (next: HermesConnection | null) => { + callbacksRef.current.onConnectionReady(next) + setConnection(next) + } + + if (!desktop) { + failDesktopBoot('Desktop IPC bridge is unavailable.') + setSessionsLoading(false) + + return () => void (cancelled = true) + } + + const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload)) + void desktop + .getBootProgress() + .then(snapshot => applyDesktopBootProgress(snapshot)) + .catch(() => undefined) + + setDesktopBootStep({ + phase: 'renderer.boot', + message: 'Starting desktop connection', + progress: 6 + }) + + const gateway = new HermesGateway() + callbacksRef.current.onGatewayReady(gateway) + setGateway(gateway) + + const offState = gateway.onState(st => void setGatewayState(st)) + const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event)) + + const offWindowState = desktop.onWindowStateChanged?.(payload => { + const current = $connection.get() + + if (current) { + publish({ ...current, ...payload }) + } + }) + + const offExit = desktop.onBackendExit(() => { + if ($desktopBoot.get().running || $desktopBoot.get().visible) { + failDesktopBoot('Hermes background process exited during startup.') + } + + notify({ + kind: 'error', + title: 'Backend stopped', + message: 'Hermes background process exited.', + durationMs: 0 + }) + }) + + async function boot() { + try { + const conn = await desktop.getConnection() + + if (cancelled) { + return + } + + setDesktopBootStep({ + phase: 'renderer.gateway.connect', + message: 'Connecting live desktop gateway', + progress: 95 + }) + publish(conn) + await gateway.connect(conn.wsUrl) + + if (cancelled) { + return + } + + setDesktopBootStep({ + phase: 'renderer.config', + message: 'Loading Hermes settings', + progress: 97 + }) + await callbacksRef.current.refreshHermesConfig() + + if (cancelled) { + return + } + + setDesktopBootStep({ + phase: 'renderer.sessions', + message: 'Loading recent sessions', + progress: 99 + }) + await callbacksRef.current.refreshSessions() + completeDesktopBoot() + } catch (err) { + if (!cancelled) { + const message = err instanceof Error ? err.message : String(err) + failDesktopBoot(message) + notifyError(err, 'Desktop boot failed') + setSessionsLoading(false) + } + } + } + + void boot() + + return () => { + cancelled = true + offState() + offEvent() + offExit() + offWindowState?.() + offBootProgress() + gateway.close() + publish(null) + callbacksRef.current.onGatewayReady(null) + setGateway(null) + } + }, []) +} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts new file mode 100644 index 000000000..1968bc672 --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -0,0 +1,94 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useRef } from 'react' + +import type { HermesGateway } from '@/hermes' +import { $gatewayState, setConnection } from '@/store/session' + +export function useGatewayRequest() { + const gatewayState = useStore($gatewayState) + const gatewayRef = useRef<HermesGateway | null>(null) + + const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>( + null + ) + + const gatewayStateRef = useRef(gatewayState) + const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null) + + useEffect(() => { + gatewayStateRef.current = gatewayState + }, [gatewayState]) + + const ensureGatewayOpen = useCallback(async () => { + const existing = gatewayRef.current + + if (!existing) { + return null + } + + if (gatewayStateRef.current === 'open') { + return existing + } + + if (reconnectingRef.current) { + return reconnectingRef.current + } + + reconnectingRef.current = (async () => { + const desktop = window.hermesDesktop + + if (!desktop) { + return null + } + + try { + const conn = await desktop.getConnection() + connectionRef.current = conn + setConnection(conn) + await existing.connect(conn.wsUrl) + + return existing + } catch { + connectionRef.current = null + setConnection(null) + + return null + } finally { + reconnectingRef.current = null + } + })() + + return reconnectingRef.current + }, []) + + const requestGateway = useCallback( + async <T>(method: string, params: Record<string, unknown> = {}) => { + const gateway = gatewayRef.current + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + try { + return await gateway.request<T>(method, params) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + + if (!/not connected|connection closed/i.test(message)) { + throw error + } + + const recovered = await ensureGatewayOpen() + + if (!recovered) { + throw error + } + + return recovered.request<T>(method, params) + } + }, + [ensureGatewayOpen] + ) + + return { connectionRef, gatewayRef, requestGateway } +} diff --git a/apps/desktop/src/app/hooks/use-route-enum-param.ts b/apps/desktop/src/app/hooks/use-route-enum-param.ts new file mode 100644 index 000000000..24de1dfe0 --- /dev/null +++ b/apps/desktop/src/app/hooks/use-route-enum-param.ts @@ -0,0 +1,38 @@ +import { useCallback, useMemo } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +// Read/write an enum-shaped URL search param (e.g. ?tab=foo). Used to make +// tabbed views survive a refresh. Always navigates with replace so tab clicks +// don't pile up in history. +export function useRouteEnumParam<T extends string>( + key: string, + values: readonly T[], + fallback: T +): [T, (next: T) => void] { + const { hash, pathname, search } = useLocation() + const navigate = useNavigate() + + const value = useMemo<T>(() => { + const raw = new URLSearchParams(search).get(key) + + return raw && values.includes(raw as T) ? (raw as T) : fallback + }, [fallback, key, search, values]) + + const setValue = useCallback( + (next: T) => { + const params = new URLSearchParams(search) + + if (next === fallback) { + params.delete(key) + } else { + params.set(key, next) + } + + const qs = params.toString() + navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true }) + }, + [fallback, hash, key, navigate, pathname, search] + ) + + return [value, setValue] +} diff --git a/apps/desktop/src/app/index.tsx b/apps/desktop/src/app/index.tsx new file mode 100644 index 000000000..ad8f79afe --- /dev/null +++ b/apps/desktop/src/app/index.tsx @@ -0,0 +1 @@ +export { DesktopController as default } from './desktop-controller' diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx new file mode 100644 index 000000000..9d0904a70 --- /dev/null +++ b/apps/desktop/src/app/messaging/index.tsx @@ -0,0 +1,758 @@ +import type * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { StatusDot, type StatusTone } from '@/components/status-dot' +import { Button } from '@/components/ui/button' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { + getMessagingPlatforms, + type MessagingEnvVarInfo, + type MessagingPlatformInfo, + updateMessagingPlatform +} from '@/hermes' +import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' + +import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { PageSearchShell } from '../page-search-shell' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' + +interface MessagingViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup +} + +type EditMap = Record<string, Record<string, string>> + +const STATE_LABELS: Record<string, string> = { + connected: 'Connected', + connecting: 'Connecting', + disabled: 'Disabled', + fatal: 'Error', + gateway_stopped: 'Messaging gateway stopped', + not_configured: 'Needs setup', + pending_restart: 'Restart needed', + retrying: 'Retrying', + startup_failed: 'Startup failed' +} + +const PLATFORM_TINTS: Record<string, string> = { + telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300', + discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300', + slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300', + mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300', + matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300', + signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300', + whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300', + bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300', + homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300', + email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300', + sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300', + dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300', + feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300', + wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300', + wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300', + weixin: 'bg-green-500/15 text-green-600 dark:text-green-300', + qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300', + yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300', + api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300', + webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300' +} + +const PILL_TONE: Record<StatusTone, string> = { + good: 'bg-primary/10 text-primary', + muted: 'bg-muted text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', + bad: 'bg-destructive/10 text-destructive' +} + +const HINT_BY_STATE: Record<string, string> = { + pending_restart: 'Restart the gateway from the status bar to apply this change.', + gateway_stopped: 'Start the gateway from the status bar to connect.' +} + +const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown') + +function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone { + if (!enabled) { + return 'muted' + } + + if (state === 'connected') { + return 'good' + } + + if (state === 'fatal' || state === 'startup_failed') { + return 'bad' + } + + return 'warn' +} + +const trimEdits = (edits: Record<string, string>): Record<string, string> => + Object.fromEntries( + Object.entries(edits) + .map(([k, v]) => [k, v.trim()]) + .filter(([, v]) => v) + ) + +const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: string; placeholder?: string }> = { + TELEGRAM_BOT_TOKEN: { + label: 'Bot token', + help: 'Create a bot with @BotFather, then paste the token it gives you.', + placeholder: '123456:ABC...' + }, + TELEGRAM_ALLOWED_USERS: { + label: 'Allowed Telegram user IDs', + help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.' + }, + TELEGRAM_PROXY: { + label: 'Proxy URL', + help: 'Only needed on networks where Telegram is blocked.', + advanced: true + }, + DISCORD_BOT_TOKEN: { + label: 'Bot token', + help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.' + }, + DISCORD_ALLOWED_USERS: { + label: 'Allowed Discord user IDs', + help: 'Recommended. Comma-separated Discord user IDs.' + }, + DISCORD_REPLY_TO_MODE: { + label: 'Reply style', + help: 'first, all, or off.', + advanced: true + }, + SLACK_BOT_TOKEN: { + label: 'Slack bot token', + help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.', + placeholder: 'xoxb-...' + }, + SLACK_APP_TOKEN: { + label: 'Slack app token', + help: 'Starts with xapp-. Required for Socket Mode.', + placeholder: 'xapp-...' + }, + SLACK_ALLOWED_USERS: { + label: 'Allowed Slack user IDs', + help: 'Recommended. Comma-separated Slack user IDs.' + }, + MATTERMOST_URL: { + label: 'Server URL', + placeholder: 'https://mattermost.example.com' + }, + MATTERMOST_TOKEN: { + label: 'Bot token' + }, + MATTERMOST_ALLOWED_USERS: { + label: 'Allowed user IDs', + help: 'Recommended. Comma-separated Mattermost user IDs.' + }, + MATRIX_HOMESERVER: { + label: 'Homeserver URL', + placeholder: 'https://matrix.org' + }, + MATRIX_ACCESS_TOKEN: { + label: 'Access token' + }, + MATRIX_USER_ID: { + label: 'Bot user ID', + placeholder: '@hermes:example.org' + }, + MATRIX_ALLOWED_USERS: { + label: 'Allowed Matrix user IDs', + help: 'Recommended. Comma-separated user IDs in @user:server format.' + }, + SIGNAL_HTTP_URL: { + label: 'Signal bridge URL', + placeholder: 'http://127.0.0.1:8080', + help: 'URL of a running signal-cli REST bridge.' + }, + SIGNAL_ACCOUNT: { + label: 'Phone number', + help: 'The number registered with your signal-cli bridge.' + }, + SIGNAL_ALLOWED_USERS: { + label: 'Allowed Signal users', + help: 'Recommended. Comma-separated Signal identifiers.' + }, + WHATSAPP_ENABLED: { + label: 'Enable WhatsApp bridge', + help: 'Set automatically by the toggle below. Leave alone unless you know you need it.', + advanced: true + }, + WHATSAPP_MODE: { + label: 'Bridge mode', + advanced: true + }, + WHATSAPP_ALLOWED_USERS: { + label: 'Allowed WhatsApp users', + help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.' + } +} + +function fieldCopy(field: MessagingEnvVarInfo) { + const copy = FIELD_COPY[field.key] || {} + + return { + label: copy.label || field.prompt || field.key, + help: copy.help || field.description, + placeholder: copy.placeholder || field.prompt, + advanced: Boolean(copy.advanced || field.advanced) + } +} + +export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) { + const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null) + const [edits, setEdits] = useState<EditMap>({}) + const [query, setQuery] = useState('') + const [refreshing, setRefreshing] = useState(false) + const [saving, setSaving] = useState<string | null>(null) + const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms]) + const [selectedId, setSelectedId] = useRouteEnumParam('platform', platformIds, platformIds[0] ?? '') + + const refreshPlatforms = useCallback(async (silent = false) => { + if (!silent) { + setRefreshing(true) + } + + try { + const result = await getMessagingPlatforms() + setPlatforms(result.platforms) + } catch (err) { + if (!silent) { + notifyError(err, 'Messaging platforms failed to load') + } + } finally { + if (!silent) { + setRefreshing(false) + } + } + }, []) + + useEffect(() => { + void refreshPlatforms() + }, [refreshPlatforms]) + + // Auto-poll while the user is on the messaging page so connection status + // updates without a manual "check" click. Pause when the tab is hidden. + useEffect(() => { + let cancelled = false + + function tick() { + if (cancelled || document.hidden) { + return + } + + void refreshPlatforms(true) + } + + const id = window.setInterval(tick, 6000) + + return () => { + cancelled = true + window.clearInterval(id) + } + }, [refreshPlatforms]) + + const selected = useMemo(() => { + if (!platforms) { + return null + } + + return platforms.find(platform => platform.id === selectedId) || platforms[0] || null + }, [platforms, selectedId]) + + const visiblePlatforms = useMemo(() => { + if (!platforms) { + return [] + } + + const q = query.trim().toLowerCase() + + if (!q) { + return platforms + } + + return platforms.filter(platform => + [platform.id, platform.name, platform.description, platform.state] + .filter(Boolean) + .some(value => String(value).toLowerCase().includes(q)) + ) + }, [platforms, query]) + + async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) { + setSaving(`enabled:${platform.id}`) + + try { + await updateMessagingPlatform(platform.id, { enabled }) + setPlatforms( + current => + current?.map(row => + row.id === platform.id + ? { + ...row, + enabled, + state: enabled ? (row.configured ? 'pending_restart' : 'not_configured') : 'disabled' + } + : row + ) ?? current + ) + notify({ + kind: 'success', + title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`, + message: 'Restart the gateway for this change to take effect.' + }) + } catch (err) { + notifyError(err, `Failed to update ${platform.name}`) + } finally { + setSaving(null) + } + } + + async function handleSave(platform: MessagingPlatformInfo) { + const env = trimEdits(edits[platform.id] || {}) + + if (Object.keys(env).length === 0) { + return + } + + setSaving(`env:${platform.id}`) + + try { + await updateMessagingPlatform(platform.id, { env }) + setEdits(current => ({ ...current, [platform.id]: {} })) + await refreshPlatforms() + notify({ + kind: 'success', + title: `${platform.name} setup saved`, + message: 'Restart the gateway to reconnect with the new credentials.' + }) + } catch (err) { + notifyError(err, `Failed to save ${platform.name}`) + } finally { + setSaving(null) + } + } + + async function handleClear(platform: MessagingPlatformInfo, key: string) { + setSaving(`clear:${key}`) + + try { + await updateMessagingPlatform(platform.id, { clear_env: [key] }) + setEdits(current => ({ + ...current, + [platform.id]: { + ...(current[platform.id] || {}), + [key]: '' + } + })) + await refreshPlatforms() + notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` }) + } catch (err) { + notifyError(err, `Failed to clear ${key}`) + } finally { + setSaving(null) + } + } + + return ( + <PageSearchShell + {...props} + onSearchChange={setQuery} + searchPlaceholder="Search messaging..." + searchTrailingAction={null} + searchValue={query} + > + {!platforms ? ( + <PageLoader label="Loading messaging platforms..." /> + ) : ( + <div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]"> + <aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r"> + <ul className="space-y-1"> + {visiblePlatforms.map(platform => ( + <li key={platform.id}> + <PlatformRow + active={selected?.id === platform.id} + onSelect={() => setSelectedId(platform.id)} + platform={platform} + /> + </li> + ))} + </ul> + </aside> + + <main className="min-h-0 overflow-hidden"> + {selected && ( + <PlatformDetail + edits={edits[selected.id] || {}} + onClear={key => void handleClear(selected, key)} + onEdit={(key, value) => + setEdits(current => ({ + ...current, + [selected.id]: { + ...(current[selected.id] || {}), + [key]: value + } + })) + } + onSave={() => void handleSave(selected)} + onToggle={enabled => void handleToggle(selected, enabled)} + platform={selected} + saving={saving} + /> + )} + </main> + </div> + )} + </PageSearchShell> + ) +} + +function PlatformRow({ + active, + onSelect, + platform +}: { + active: boolean + onSelect: () => void + platform: MessagingPlatformInfo +}) { + return ( + <button + className={cn( + 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors', + active + ? 'bg-(--ui-bg-tertiary) text-foreground' + : 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground' + )} + onClick={onSelect} + type="button" + > + <PlatformAvatar platformId={platform.id} platformName={platform.name} /> + <span className="flex min-w-0 flex-1 items-center justify-between gap-2"> + <span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span> + <StatusDot tone={stateTone(platform)} /> + </span> + </button> + ) +} + +function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) { + return ( + <span + className={cn( + 'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium', + PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)' + )} + > + {platformName.charAt(0).toUpperCase()} + </span> + ) +} + +function PlatformDetail({ + edits, + onClear, + onEdit, + onSave, + onToggle, + platform, + saving +}: { + edits: Record<string, string> + onClear: (key: string) => void + onEdit: (key: string, value: string) => void + onSave: () => void + onToggle: (enabled: boolean) => void + platform: MessagingPlatformInfo + saving: string | null +}) { + const [showAdvanced, setShowAdvanced] = useState(false) + + const hasEdits = Object.keys(trimEdits(edits)).length > 0 + const requiredFields = platform.env_vars.filter(field => field.required) + const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced) + const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced) + const hiddenCount = advancedFields.length + const isSavingEnv = saving === `env:${platform.id}` + + return ( + <div className="flex h-full min-h-0 flex-col"> + <div className="min-h-0 flex-1 overflow-y-auto"> + <div className="mx-auto max-w-2xl space-y-5 px-5 py-4"> + <header className="flex items-start gap-3"> + <PlatformAvatar platformId={platform.id} platformName={platform.name} /> + <div className="min-w-0 flex-1"> + <h3 className="text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3> + <p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {platform.description} + </p> + <div className="mt-3 flex flex-wrap items-center gap-2"> + <StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill> + <SetupPill active={platform.configured}> + {platform.configured ? 'Credentials set' : 'Needs setup'} + </SetupPill> + {!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>} + </div> + <PlatformHint platform={platform} /> + </div> + </header> + + {platform.error_message && ( + <div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-destructive"> + <AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> + <span>{platform.error_message}</span> + </div> + )} + + <section> + <SectionTitle>Get your credentials</SectionTitle> + <p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {introCopy(platform)} + </p> + <div className="mt-3"> + <Button asChild size="sm" variant="outline"> + <a href={platform.docs_url} rel="noreferrer" target="_blank"> + Open setup guide + <ExternalLink className="size-3.5" /> + </a> + </Button> + </div> + </section> + + <section> + <SectionTitle>Required</SectionTitle> + <div className="mt-3 space-y-4"> + {requiredFields.length > 0 ? ( + requiredFields.map(field => ( + <MessagingField + edits={edits} + field={field} + key={field.key} + onClear={onClear} + onEdit={onEdit} + saving={saving} + /> + )) + ) : ( + <p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + This platform does not need a token here. Use the setup guide above, then enable it below. + </p> + )} + </div> + </section> + + {optionalFields.length > 0 && ( + <section> + <SectionTitle>Recommended</SectionTitle> + <div className="mt-3 space-y-4"> + {optionalFields.map(field => ( + <MessagingField + edits={edits} + field={field} + key={field.key} + onClear={onClear} + onEdit={onEdit} + saving={saving} + /> + ))} + </div> + </section> + )} + + {hiddenCount > 0 && ( + <section> + <button + className="flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground" + onClick={() => setShowAdvanced(value => !value)} + type="button" + > + <span>Advanced ({hiddenCount})</span> + <DisclosureCaret open={showAdvanced} size="0.875rem" /> + </button> + {showAdvanced && ( + <div className="mt-3 space-y-4"> + {advancedFields.map(field => ( + <MessagingField + edits={edits} + field={field} + key={field.key} + onClear={onClear} + onEdit={onEdit} + saving={saving} + /> + ))} + </div> + )} + </section> + )} + </div> + </div> + + <footer className="border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-5 py-2.5"> + <div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2"> + <label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]"> + <Switch + aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`} + checked={platform.enabled} + disabled={saving === `enabled:${platform.id}`} + onCheckedChange={onToggle} + /> + <span className="text-xs font-medium text-muted-foreground"> + {platform.enabled ? 'Enabled' : 'Disabled'} + </span> + </label> + + <div className="ml-auto flex items-center gap-2"> + {hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>} + <Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm"> + <Save /> + {isSavingEnv ? 'Saving...' : 'Save changes'} + </Button> + </div> + </div> + </footer> + </div> + ) +} + +const PLATFORM_INTRO: Record<string, string> = { + telegram: + 'In Telegram, talk to @BotFather, run /newbot, and copy the token it gives you. Then grab your numeric user ID from @userinfobot.', + discord: + 'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.', + slack: + 'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).', + mattermost: + 'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.', + matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.', + signal: + 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.', + whatsapp: + 'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.', + bluebubbles: + 'Run BlueBubbles Server on a Mac with iMessage, expose its API, then point Hermes at the URL with the server password.', + homeassistant: + 'In Home Assistant, open your profile and create a long-lived access token. Paste it here along with your HA URL.', + email: + 'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.', + sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.', + dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.', + feishu: + 'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.', + wecom: + 'Add a group robot in WeCom and copy its webhook key as WECOM_BOT_ID. Send-only — use the WeCom (app) option for two-way.', + wecom_callback: + 'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.', + weixin: + 'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.', + qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.', + api_server: + 'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.', + webhook: + 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.' +} + +const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description + +function MessagingField({ + edits, + field, + onClear, + onEdit, + saving +}: { + edits: Record<string, string> + field: MessagingEnvVarInfo + onClear: (key: string) => void + onEdit: (key: string, value: string) => void + saving: string | null +}) { + const copy = fieldCopy(field) + + return ( + <div className="space-y-1.5"> + <div className="flex flex-wrap items-baseline gap-2"> + <label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}> + {copy.label} + </label> + {field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>} + </div> + <div className="flex items-center gap-2"> + <Input + className="h-9 rounded-lg font-mono text-sm" + id={`messaging-field-${field.key}`} + onChange={event => onEdit(field.key, event.target.value)} + placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} + type={field.is_password ? 'password' : 'text'} + value={edits[field.key] || ''} + /> + {field.url && ( + <Button asChild size="icon-sm" title="Open docs" variant="ghost"> + <a href={field.url} rel="noreferrer" target="_blank"> + <ExternalLink className="size-3.5" /> + </a> + </Button> + )} + {field.is_set && ( + <Button + disabled={saving === `clear:${field.key}`} + onClick={() => onClear(field.key)} + size="icon-sm" + title={`Clear ${field.key}`} + variant="ghost" + > + <Trash2 className="size-3.5" /> + </Button> + )} + </div> + {copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>} + </div> + ) +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4> +} + +function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) { + if (!platform.enabled || platform.state === 'connected') { + return null + } + + const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped) + + return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null +} + +function StatePill({ children, tone }: { children: string; tone: StatusTone }) { + return ( + <span + className={cn( + 'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium', + PILL_TONE[tone] + )} + > + <StatusDot tone={tone} /> + {children} + </span> + ) +} + +function SetupPill({ active, children }: { active: boolean; children: string }) { + return ( + <span + className={cn( + 'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium', + PILL_TONE[active ? 'good' : 'muted'] + )} + > + {children} + </span> + ) +} diff --git a/apps/desktop/src/app/model-picker-overlay.tsx b/apps/desktop/src/app/model-picker-overlay.tsx new file mode 100644 index 000000000..4921ad688 --- /dev/null +++ b/apps/desktop/src/app/model-picker-overlay.tsx @@ -0,0 +1,42 @@ +import { useStore } from '@nanostores/react' +import type * as React from 'react' + +import { ModelPickerDialog } from '@/components/model-picker' +import type { HermesGateway } from '@/hermes' +import { + $activeSessionId, + $currentModel, + $currentProvider, + $gatewayState, + $modelPickerOpen, + setModelPickerOpen +} from '@/store/session' + +interface ModelPickerOverlayProps { + gateway?: HermesGateway + onSelect: React.ComponentProps<typeof ModelPickerDialog>['onSelect'] +} + +export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) { + const activeSessionId = useStore($activeSessionId) + const currentModel = useStore($currentModel) + const currentProvider = useStore($currentProvider) + const gatewayOpen = useStore($gatewayState) === 'open' + const open = useStore($modelPickerOpen) + + if (!gatewayOpen) { + return null + } + + return ( + <ModelPickerDialog + currentModel={currentModel} + currentProvider={currentProvider} + gw={gateway} + onOpenChange={setModelPickerOpen} + onSelect={onSelect} + open={open} + sessionId={activeSessionId} + /> + ) +} diff --git a/apps/desktop/src/app/overlays/overlay-chrome.tsx b/apps/desktop/src/app/overlays/overlay-chrome.tsx new file mode 100644 index 000000000..23a57da4e --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-chrome.tsx @@ -0,0 +1,66 @@ +import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +export const overlayCardClass = + 'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]' + +interface OverlayCardProps extends ComponentProps<'div'> { + children: ReactNode +} + +interface OverlayActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { + tone?: 'default' | 'danger' | 'subtle' +} + +export function OverlayCard({ children, className, ...props }: OverlayCardProps) { + return ( + <div className={cn(overlayCardClass, className)} {...props}> + {children} + </div> + ) +} + +export function OverlayActionButton({ + children, + className, + tone = 'default', + type = 'button', + ...props +}: OverlayActionButtonProps) { + return ( + <button + className={cn( + 'inline-flex h-8 items-center rounded-md border px-3 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-45', + tone === 'default' && + 'border-[color-mix(in_srgb,var(--dt-border)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_80%,transparent)] text-foreground hover:bg-[color-mix(in_srgb,var(--dt-muted)_46%,var(--dt-card))]', + tone === 'subtle' && + 'h-7 border-transparent px-2 text-muted-foreground hover:border-[color-mix(in_srgb,var(--dt-border)_54%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] hover:text-foreground', + tone === 'danger' && + 'h-7 border-transparent px-2 text-destructive hover:border-[color-mix(in_srgb,var(--dt-destructive)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-destructive)_10%,transparent)] hover:text-destructive', + className + )} + type={type} + {...props} + > + {children} + </button> + ) +} + +interface OverlayIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { + children: ReactNode +} + +export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) { + return ( + <OverlayActionButton + className={cn('h-7 w-7 justify-center px-0 [&_svg]:size-4', className)} + tone="subtle" + type={type} + {...props} + > + {children} + </OverlayActionButton> + ) +} diff --git a/apps/desktop/src/app/overlays/overlay-search-input.tsx b/apps/desktop/src/app/overlays/overlay-search-input.tsx new file mode 100644 index 000000000..ab3603d4d --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-search-input.tsx @@ -0,0 +1,77 @@ +import type { ReactNode, RefObject } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { Loader2, Search } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface OverlaySearchInputProps { + placeholder: string + value: string + onChange: (value: string) => void + containerClassName?: string + inputClassName?: string + loading?: boolean + onClear?: () => void + inputRef?: RefObject<HTMLInputElement | null> + trailingAction?: ReactNode +} + +export function OverlaySearchInput({ + placeholder, + value, + onChange, + containerClassName, + inputClassName, + loading = false, + onClear, + inputRef, + trailingAction +}: OverlaySearchInputProps) { + const clear = onClear ?? (() => onChange('')) + const hasTrailing = Boolean(trailingAction) + + return ( + <div className={cn('relative', containerClassName)}> + <Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" /> + <Input + className={cn( + 'relative z-0 h-8 rounded-lg py-2 pl-8 text-[length:var(--conversation-text-font-size)]', + hasTrailing || loading || value ? 'pr-16' : 'pr-8', + inputClassName + )} + onChange={event => onChange(event.target.value)} + placeholder={placeholder} + ref={inputRef} + value={value} + /> + <div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5"> + {trailingAction} + {loading ? ( + <Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" /> + ) : value ? ( + <Button + aria-label="Clear search" + className="text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground" + onClick={clear} + size="icon-xs" + variant="ghost" + > + <Codicon name="close" size="0.875rem" /> + </Button> + ) : null} + </div> + </div> + ) +} + +export function PageSearchInput(props: OverlaySearchInputProps) { + return ( + <OverlaySearchInput + {...props} + containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)} + inputClassName={cn('h-8 rounded-lg py-2 pl-8', props.inputClassName)} + /> + ) +} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx new file mode 100644 index 000000000..2ce6c85bc --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react' + +import type { IconComponent } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface OverlaySplitLayoutProps { + children: ReactNode + className?: string +} + +interface OverlaySidebarProps { + children: ReactNode + className?: string +} + +interface OverlayMainProps { + children: ReactNode + className?: string +} + +interface OverlayNavItemProps { + active: boolean + icon: IconComponent + label: string + onClick: () => void + trailing?: ReactNode +} + +export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutProps) { + return ( + <div + className={cn( + 'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1', + className + )} + > + {children} + </div> + ) +} + +export function OverlaySidebar({ children, className }: OverlaySidebarProps) { + return ( + <aside + className={cn( + 'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3', + className + )} + > + {children} + </aside> + ) +} + +export function OverlayMain({ children, className }: OverlayMainProps) { + return ( + <main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main> + ) +} + +export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) { + return ( + <button + className={cn( + 'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors', + active + ? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground' + : 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground' + )} + onClick={onClick} + type="button" + > + <Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} /> + <span className="min-w-0 flex-1 truncate">{label}</span> + {trailing} + </button> + ) +} diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx new file mode 100644 index 000000000..8408be6b3 --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -0,0 +1,87 @@ +import { type ReactNode, useEffect } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' + +interface OverlayViewProps { + children: ReactNode + onClose: () => void + closeLabel?: string + contentClassName?: string + headerContent?: ReactNode + rootClassName?: string +} + +export function OverlayView({ + children, + onClose, + closeLabel = 'Close', + contentClassName, + headerContent, + rootClassName +}: OverlayViewProps) { + const closeOverlay = () => { + triggerHaptic('close') + onClose() + } + + // Esc dismisses every OverlayView-based overlay. Nested Radix dialogs + // stop propagation themselves, so opening (e.g.) the model picker inside + // Settings still closes the picker first instead of the underlying overlay. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) { + return + } + + event.preventDefault() + triggerHaptic('close') + onClose() + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + return ( + <div + className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[0.125rem] sm:p-6" + onClick={event => { + if (event.target === event.currentTarget) { + closeOverlay() + } + }} + role="presentation" + > + <div + className={cn( + 'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) shadow-md', + rootClassName + )} + > + <div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]"> + {headerContent && ( + <div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]"> + {headerContent} + </div> + )} + + <Button + aria-label={closeLabel} + className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-md text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]" + onClick={closeOverlay} + size="icon" + variant="ghost" + > + <Codicon name="close" size="1rem" /> + </Button> + </div> + + <div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div> + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/page-search-shell.tsx b/apps/desktop/src/app/page-search-shell.tsx new file mode 100644 index 000000000..6e7d3432b --- /dev/null +++ b/apps/desktop/src/app/page-search-shell.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +import { PageSearchInput } from './overlays/overlay-search-input' + +interface PageSearchShellProps extends React.ComponentProps<'section'> { + children: ReactNode + filters?: ReactNode + onSearchChange: (value: string) => void + searchPlaceholder: string + searchTrailingAction?: ReactNode + searchValue: string +} + +export function PageSearchShell({ + children, + className, + filters, + onSearchChange, + searchPlaceholder, + searchTrailingAction, + searchValue, + ...props +}: PageSearchShellProps) { + return ( + <section + {...props} + className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)} + > + <div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5"> + {/* Reserve the top-right titlebar tools + native window-controls + footprint so the full-width search input never slides under them + (this header sits in the titlebar row at the window top). */} + <div + style={{ + paddingRight: + 'max(0px, calc(var(--titlebar-tools-right, 0px) + var(--titlebar-tools-width, 0px) - 0.75rem))' + }} + > + <PageSearchInput + onChange={onSearchChange} + placeholder={searchPlaceholder} + trailingAction={searchTrailingAction} + value={searchValue} + /> + </div> + {filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null} + </div> + <div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div> + </section> + ) +} diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx new file mode 100644 index 000000000..29a9c7538 --- /dev/null +++ b/apps/desktop/src/app/profiles/index.tsx @@ -0,0 +1,707 @@ +import type * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { + createProfile, + deleteProfile, + getProfiles, + getProfileSetupCommand, + getProfileSoul, + type ProfileInfo, + renameProfile, + updateProfileSoul +} from '@/hermes' +import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' + +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { titlebarHeaderBaseClass } from '../shell/titlebar' +import type { SetTitlebarToolGroup } from '../shell/titlebar-controls' + +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ + +const PROFILE_NAME_HINT = 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.' + +function isValidProfileName(name: string): boolean { + return PROFILE_NAME_RE.test(name.trim()) +} + +interface ProfilesViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup + setTitlebarToolGroup?: SetTitlebarToolGroup +} + +export function ProfilesView({ + setStatusbarItemGroup: _setStatusbarItemGroup, + setTitlebarToolGroup, + ...props +}: ProfilesViewProps) { + const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null) + const [refreshing, setRefreshing] = useState(false) + const [selectedName, setSelectedName] = useState<null | string>(null) + const [createOpen, setCreateOpen] = useState(false) + const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null) + const [deleting, setDeleting] = useState(false) + + const refresh = useCallback(async () => { + setRefreshing(true) + + try { + const { profiles: list } = await getProfiles() + setProfiles(list) + setSelectedName(current => { + if (current && list.some(p => p.name === current)) { + return current + } + + return list.find(p => p.is_default)?.name ?? list[0]?.name ?? null + }) + } catch (err) { + notifyError(err, 'Failed to load profiles') + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + useEffect(() => { + if (!setTitlebarToolGroup) { + return + } + + setTitlebarToolGroup('profiles', [ + { + disabled: refreshing, + icon: <Codicon name="refresh" spinning={refreshing} />, + id: 'refresh-profiles', + label: refreshing ? 'Refreshing profiles' : 'Refresh profiles', + onSelect: () => void refresh() + } + ]) + + return () => setTitlebarToolGroup('profiles', []) + }, [refresh, refreshing, setTitlebarToolGroup]) + + const selected = useMemo(() => { + if (!profiles) { + return null + } + + return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null + }, [profiles, selectedName]) + + const handleCreate = useCallback( + async (name: string, cloneFromDefault: boolean) => { + const trimmed = name.trim() + + if (!isValidProfileName(trimmed)) { + throw new Error(PROFILE_NAME_HINT) + } + + await createProfile({ name: trimmed, clone_from_default: cloneFromDefault }) + notify({ kind: 'success', title: 'Profile created', message: trimmed }) + setSelectedName(trimmed) + await refresh() + }, + [refresh] + ) + + const handleRename = useCallback( + async (from: string, to: string): Promise<void> => { + const target = to.trim() + + if (target === from) { + return + } + + if (!isValidProfileName(target)) { + throw new Error(PROFILE_NAME_HINT) + } + + await renameProfile(from, target) + notify({ kind: 'success', title: 'Profile renamed', message: `${from} → ${target}` }) + setSelectedName(target) + await refresh() + }, + [refresh] + ) + + const handleConfirmDelete = useCallback(async () => { + if (!pendingDelete) { + return + } + + setDeleting(true) + + try { + await deleteProfile(pendingDelete.name) + notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name }) + setPendingDelete(null) + setSelectedName(null) + await refresh() + } catch (err) { + notifyError(err, 'Failed to delete profile') + } finally { + setDeleting(false) + } + }, [pendingDelete, refresh]) + + return ( + <section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background"> + <header className={titlebarHeaderBaseClass}> + <h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Profiles</h2> + <span className="pointer-events-auto text-xs text-muted-foreground"> + {profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''} + </span> + </header> + + <div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85"> + {!profiles ? ( + <PageLoader label="Loading profiles..." /> + ) : ( + <div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]"> + <aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r"> + <div className="border-b border-border/40 p-2"> + <Button className="w-full" onClick={() => setCreateOpen(true)} size="sm"> + <Codicon name="add" /> + New profile + </Button> + </div> + <ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2"> + {profiles.map(profile => ( + <li key={profile.name}> + <ProfileRow + active={selected?.name === profile.name} + onSelect={() => setSelectedName(profile.name)} + profile={profile} + /> + </li> + ))} + {profiles.length === 0 && ( + <li className="px-2 py-4 text-center text-xs text-muted-foreground">No profiles yet.</li> + )} + </ul> + </aside> + + <main className="min-h-0 overflow-hidden"> + {selected ? ( + <ProfileDetail + key={selected.name} + onDelete={() => setPendingDelete(selected)} + onRename={newName => handleRename(selected.name, newName)} + profile={selected} + /> + ) : ( + <div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground"> + <div> + <Users className="mx-auto size-6 text-muted-foreground/60" /> + <p className="mt-3">Select a profile to view its details.</p> + </div> + </div> + )} + </main> + </div> + )} + </div> + + <CreateProfileDialog + onClose={() => setCreateOpen(false)} + onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)} + open={createOpen} + /> + + <Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Delete profile?</DialogTitle> + <DialogDescription> + {pendingDelete ? ( + <> + This will delete <span className="font-medium text-foreground">{pendingDelete.name}</span> and remove + its <span className="font-mono text-xs">{pendingDelete.path}</span> directory. This cannot be undone. + </> + ) : null} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline"> + Cancel + </Button> + <Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive"> + {deleting ? 'Deleting...' : 'Delete'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </section> + ) +} + +function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) { + return ( + <button + className={cn( + 'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors', + active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60' + )} + onClick={onSelect} + type="button" + > + <span className="flex w-full items-center justify-between gap-2"> + <span className="truncate text-sm font-medium">{profile.name}</span> + {profile.is_default && <span className="text-[0.6rem] text-primary">default</span>} + </span> + <span className="text-[0.66rem] text-muted-foreground"> + {profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'} + {profile.has_env ? ' · env' : ''} + </span> + </button> + ) +} + +function ProfileDetail({ + onDelete, + onRename, + profile +}: { + onDelete: () => void + onRename: (newName: string) => Promise<void> + profile: ProfileInfo +}) { + const [renameOpen, setRenameOpen] = useState(false) + const [copying, setCopying] = useState(false) + + const handleCopySetup = useCallback(async () => { + setCopying(true) + + try { + const { command } = await getProfileSetupCommand(profile.name) + await navigator.clipboard.writeText(command) + notify({ kind: 'success', title: 'Setup command copied', message: command }) + } catch (err) { + notifyError(err, 'Failed to copy setup command') + } finally { + setCopying(false) + } + }, [profile.name]) + + return ( + <div className="flex h-full min-h-0 flex-col"> + <div className="min-h-0 flex-1 overflow-y-auto"> + <div className="mx-auto max-w-2xl space-y-6 px-6 py-6"> + <header className="space-y-3"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="min-w-0"> + <div className="flex flex-wrap items-center gap-2"> + <h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3> + {profile.is_default && ( + <span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary"> + Default + </span> + )} + {profile.has_env && ( + <span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground"> + .env + </span> + )} + </div> + <p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}> + {profile.path} + </p> + </div> + <div className="flex shrink-0 items-center gap-1"> + {!profile.is_default && ( + <Button onClick={() => setRenameOpen(true)} size="sm" variant="outline"> + <Pencil /> + Rename + </Button> + )} + <Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline"> + <Terminal /> + {copying ? 'Copying...' : 'Copy setup'} + </Button> + {!profile.is_default && ( + <Button + className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive" + onClick={onDelete} + size="sm" + variant="ghost" + > + <Trash2 /> + Delete + </Button> + )} + </div> + </div> + + <dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2"> + <DetailRow label="Model"> + {profile.model ? ( + <> + <span className="font-mono">{profile.model}</span> + {profile.provider && <span className="text-muted-foreground"> · {profile.provider}</span>} + </> + ) : ( + <span className="text-muted-foreground">Not set</span> + )} + </DetailRow> + <DetailRow label="Skills">{profile.skill_count}</DetailRow> + </dl> + </header> + + <SoulEditor profileName={profile.name} /> + </div> + </div> + + <RenameProfileDialog + currentName={profile.name} + onClose={() => setRenameOpen(false)} + onRename={async newName => { + await onRename(newName) + setRenameOpen(false) + }} + open={renameOpen} + /> + </div> + ) +} + +function DetailRow({ children, label }: { children: React.ReactNode; label: string }) { + return ( + <div className="flex flex-wrap items-baseline gap-2"> + <dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt> + <dd className="text-sm text-foreground">{children}</dd> + </div> + ) +} + +function SoulEditor({ profileName }: { profileName: string }) { + const [content, setContent] = useState('') + const [original, setOriginal] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState<null | string>(null) + const requestRef = useRef<string>(profileName) + + useEffect(() => { + requestRef.current = profileName + setLoading(true) + setError(null) + setContent('') + setOriginal('') + + void (async () => { + try { + const soul = await getProfileSoul(profileName) + + if (requestRef.current === profileName) { + setContent(soul.content) + setOriginal(soul.content) + } + } catch (err) { + if (requestRef.current === profileName) { + setError(err instanceof Error ? err.message : 'Failed to load SOUL.md') + } + } finally { + if (requestRef.current === profileName) { + setLoading(false) + } + } + })() + }, [profileName]) + + const dirty = content !== original + const isEmpty = !content.trim() + + async function handleSave() { + setSaving(true) + setError(null) + + try { + await updateProfileSoul(profileName, content) + setOriginal(content) + notify({ kind: 'success', title: 'SOUL.md saved', message: profileName }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save SOUL.md') + } finally { + setSaving(false) + } + } + + return ( + <section className="space-y-2"> + <div className="flex flex-wrap items-baseline justify-between gap-2"> + <div> + <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">SOUL.md</h4> + <p className="text-xs text-muted-foreground"> + The system prompt and persona instructions baked into this profile. + </p> + </div> + {dirty && <span className="text-[0.65rem] text-muted-foreground">Unsaved changes</span>} + </div> + + {loading ? ( + <div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground"> + Loading SOUL.md... + </div> + ) : ( + <Textarea + className="min-h-72 font-mono text-xs leading-5" + onChange={event => setContent(event.target.value)} + placeholder={isEmpty ? 'Empty SOUL.md — start writing the persona...' : undefined} + value={content} + /> + )} + + {error && ( + <div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"> + <AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> + <span>{error}</span> + </div> + )} + + <div className="flex justify-end"> + <Button disabled={!dirty || saving || loading} onClick={() => void handleSave()} size="sm"> + <Save /> + {saving ? 'Saving...' : 'Save SOUL.md'} + </Button> + </div> + </section> + ) +} + +function CreateProfileDialog({ + onClose, + onCreate, + open +}: { + onClose: () => void + onCreate: (name: string, cloneFromDefault: boolean) => Promise<void> + open: boolean +}) { + const [name, setName] = useState('') + const [cloneFromDefault, setCloneFromDefault] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState<null | string>(null) + + useEffect(() => { + if (!open) { + return + } + + setName('') + setCloneFromDefault(true) + setError(null) + setSaving(false) + }, [open]) + + const trimmed = name.trim() + const invalid = trimmed !== '' && !isValidProfileName(trimmed) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + if (!trimmed || invalid) { + setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.') + + return + } + + setSaving(true) + setError(null) + + try { + await onCreate(trimmed, cloneFromDefault) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create profile') + } finally { + setSaving(false) + } + } + + return ( + <Dialog onOpenChange={value => !value && !saving && onClose()} open={open}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>New profile</DialogTitle> + <DialogDescription> + Profiles are independent Hermes environments: separate config, skills, and SOUL.md. + </DialogDescription> + </DialogHeader> + + <form className="grid gap-4" onSubmit={handleSubmit}> + <div className="grid gap-1.5"> + <label className="text-xs font-medium" htmlFor="new-profile-name"> + Name + </label> + <Input + aria-invalid={invalid} + autoFocus + id="new-profile-name" + onChange={event => setName(event.target.value)} + placeholder="my-profile" + value={name} + /> + <p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}> + {PROFILE_NAME_HINT} + </p> + </div> + + <label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm"> + <input + checked={cloneFromDefault} + className="size-4 accent-primary" + onChange={event => setCloneFromDefault(event.target.checked)} + type="checkbox" + /> + <span> + <span className="font-medium">Clone from default</span> + <span className="ml-2 text-xs text-muted-foreground"> + Copy config, skills, and SOUL.md from your default profile. + </span> + </span> + </label> + + {error && ( + <div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"> + <AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> + <span>{error}</span> + </div> + )} + + <DialogFooter> + <Button disabled={saving} onClick={onClose} type="button" variant="outline"> + Cancel + </Button> + <Button disabled={saving || !trimmed || invalid} type="submit"> + {saving ? 'Creating...' : 'Create profile'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +} + +function RenameProfileDialog({ + currentName, + onClose, + onRename, + open +}: { + currentName: string + onClose: () => void + onRename: (newName: string) => Promise<void> + open: boolean +}) { + const [name, setName] = useState(currentName) + const [saving, setSaving] = useState(false) + const [error, setError] = useState<null | string>(null) + + useEffect(() => { + if (!open) { + return + } + + setName(currentName) + setError(null) + setSaving(false) + }, [currentName, open]) + + const trimmed = name.trim() + const unchanged = trimmed === currentName + const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + if (unchanged) { + onClose() + + return + } + + if (!trimmed || invalid) { + setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.') + + return + } + + setSaving(true) + setError(null) + + try { + await onRename(trimmed) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rename profile') + } finally { + setSaving(false) + } + } + + return ( + <Dialog onOpenChange={value => !value && !saving && onClose()} open={open}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Rename profile</DialogTitle> + <DialogDescription> + Renaming updates the profile directory and any wrapper scripts in{' '} + <span className="font-mono">~/.local/bin</span>. + </DialogDescription> + </DialogHeader> + + <form className="grid gap-3" onSubmit={handleSubmit}> + <div className="grid gap-1.5"> + <label className="text-xs font-medium" htmlFor="rename-profile-name"> + New name + </label> + <Input + aria-invalid={invalid} + autoFocus + id="rename-profile-name" + onChange={event => setName(event.target.value)} + value={name} + /> + <p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}> + {PROFILE_NAME_HINT} + </p> + </div> + + {error && ( + <div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"> + <AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> + <span>{error}</span> + </div> + )} + + <DialogFooter> + <Button disabled={saving} onClick={onClose} type="button" variant="outline"> + Cancel + </Button> + <Button disabled={saving || invalid || unchanged} type="submit"> + {saving ? 'Renaming...' : 'Rename'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.ts b/apps/desktop/src/app/right-sidebar/files/ipc.ts new file mode 100644 index 000000000..843ebe761 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/files/ipc.ts @@ -0,0 +1,161 @@ +import ignore from 'ignore' + +import type { HermesReadDirEntry, HermesReadDirResult } from '@/global' + +export type ProjectTreeEntry = HermesReadDirEntry + +interface GitignoreRule { + base: string + ig: ReturnType<typeof ignore> +} + +const gitRootCache = new Map<string, Promise<string | null>>() +const gitignoreCache = new Map<string, Promise<GitignoreRule | null>>() + +function decodeDataUrl(dataUrl: string) { + const match = dataUrl.match(/^data:[^,]*,(.*)$/) + const data = match?.[1] || '' + const isBase64 = dataUrl.slice(0, dataUrl.indexOf(',')).includes(';base64') + + if (!isBase64) { + return decodeURIComponent(data) + } + + const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0)) + + return new TextDecoder().decode(bytes) +} + +function clean(path: string) { + return path.replace(/\/+$/, '') || '/' +} + +/** Strict POSIX-style relative path; null if `child` is not inside `root`. */ +function relativeTo(root: string, child: string) { + const r = clean(root) + const c = clean(child) + + if (c === r) { + return '' + } + + return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null +} + +/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */ +function ancestorDirs(root: string, dir: string) { + const r = clean(root) + const rel = relativeTo(r, dir) + + if (rel === null || rel === '') { + return [r] + } + + const dirs = [r] + let current = r + + for (const part of rel.split('/').filter(Boolean)) { + current = `${current}/${part}` + dirs.push(current) + } + + return dirs +} + +async function gitRootFor(start: string) { + if (!window.hermesDesktop?.gitRoot) { + return null + } + + const key = clean(start) + let cached = gitRootCache.get(key) + + if (!cached) { + cached = window.hermesDesktop.gitRoot(key) + gitRootCache.set(key, cached) + } + + return cached +} + +/** Read .gitignore at `dir` if it actually exists — never probe missing files. */ +async function readGitignore(dir: string): Promise<GitignoreRule | null> { + if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) { + return null + } + + try { + const listing = await window.hermesDesktop.readDir(dir) + + if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) { + return null + } + + const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`)) + + return { base: dir, ig: ignore().add(text) } + } catch { + return null + } +} + +async function gitignoreFor(dir: string) { + const key = clean(dir) + let cached = gitignoreCache.get(key) + + if (!cached) { + cached = readGitignore(key) + gitignoreCache.set(key, cached) + } + + return cached +} + +function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) { + return rules.some(rule => { + const rel = relativeTo(rule.base, entry.path) + + if (rel === null || rel === '') { + return false + } + + return rule.ig.ignores(entry.isDirectory ? `${rel}/` : rel) + }) +} + +async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, dirPath: string) { + const root = await gitRootFor(rootPath) + + if (!root) { + return entries + } + + const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter((r): r is GitignoreRule => + Boolean(r) + ) + + return rules.length > 0 ? entries.filter(entry => !ignoredBy(rules, entry)) : entries +} + +export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> { + if (!window.hermesDesktop) { + return { entries: [], error: 'no-bridge' } + } + + const result = await window.hermesDesktop.readDir(dirPath) + + return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) } +} + +export function clearProjectDirCache(rootPath?: string) { + if (!rootPath) { + gitRootCache.clear() + gitignoreCache.clear() + + return + } + + const key = clean(rootPath) + gitRootCache.delete(key) + gitignoreCache.delete(key) +} diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx new file mode 100644 index 000000000..95bea5fd4 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx @@ -0,0 +1,215 @@ +import { useCallback, useRef, useState } from 'react' +import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist' + +import { Codicon } from '@/components/ui/codicon' +import { useResizeObserver } from '@/hooks/use-resize-observer' +import { cn } from '@/lib/utils' + +import type { TreeNode } from './use-project-tree' + +const ROW_HEIGHT = 22 +const INDENT = 10 + +interface ProjectTreeProps { + data: TreeNode[] + onActivateFile: (path: string) => void + onActivateFolder: (path: string) => void + onLoadChildren: (id: string) => void | Promise<void> + onNodeOpenChange: (id: string, open: boolean) => void + onPreviewFile?: (path: string) => void + openState: Record<string, boolean> +} + +export function ProjectTree({ + data, + onActivateFile, + onActivateFolder, + onLoadChildren, + onNodeOpenChange, + onPreviewFile, + openState +}: ProjectTreeProps) { + const containerRef = useRef<HTMLDivElement | null>(null) + const treeRef = useRef<TreeApi<TreeNode> | null>(null) + const [size, setSize] = useState({ height: 0, width: 0 }) + + const syncTreeSize = useCallback(() => { + const el = containerRef.current + + if (!el) { + return + } + + const { height, width } = el.getBoundingClientRect() + + setSize(prev => { + if (prev.height === height && prev.width === width) { + return prev + } + + return { height, width } + }) + }, []) + + useResizeObserver(syncTreeSize, containerRef) + + const handleToggle = useCallback( + (id: string) => { + const node = treeRef.current?.get(id) + + if (!node) { + return + } + + onNodeOpenChange(id, node.isOpen) + + if (node.isOpen && node.data.children === undefined) { + void onLoadChildren(id) + } + }, + [onLoadChildren, onNodeOpenChange] + ) + + const handleActivate = useCallback( + (node: NodeApi<TreeNode>) => { + if (!node.data.isDirectory) { + onPreviewFile?.(node.data.id) + } + }, + [onPreviewFile] + ) + + return ( + <div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}> + {size.height > 0 && size.width > 0 ? ( + <Tree<TreeNode> + childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)} + data={data} + disableDrag + disableDrop + disableEdit + height={size.height} + indent={INDENT} + initialOpenState={openState} + onActivate={handleActivate} + onToggle={handleToggle} + openByDefault={false} + padding={0} + ref={treeRef} + rowHeight={ROW_HEIGHT} + width={size.width} + > + {props => ( + <ProjectTreeRow + {...props} + onAttachFile={onActivateFile} + onAttachFolder={onActivateFolder} + onPreviewFile={onPreviewFile} + /> + )} + </Tree> + ) : ( + <TreeSizingState /> + )} + </div> + ) +} + +function TreeSizingState() { + return ( + <div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)"> + Loading files... + </div> + ) +} + +function ProjectTreeRow({ + dragHandle, + node, + onAttachFile, + onAttachFolder, + onPreviewFile, + style +}: NodeRendererProps<TreeNode> & { + onAttachFile: (path: string) => void + onAttachFolder: (path: string) => void + onPreviewFile?: (path: string) => void +}) { + const isFolder = node.data.isDirectory + const isPlaceholder = node.data.id.endsWith('::__loading__') + + return ( + <div + aria-expanded={isFolder ? node.isOpen : undefined} + aria-selected={node.isSelected} + className={cn( + 'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--ui-row-hover-background) hover:text-foreground', + node.isSelected && 'bg-(--ui-row-active-background) text-foreground', + isPlaceholder && 'pointer-events-none italic text-muted-foreground/70' + )} + draggable={!isPlaceholder} + onClick={event => { + event.stopPropagation() + + if (isPlaceholder) { + return + } + + if (event.shiftKey) { + ;(isFolder ? onAttachFolder : onAttachFile)(node.data.id) + + return + } + + if (isFolder) { + node.toggle() + } else { + node.select() + } + }} + onDoubleClick={event => { + event.stopPropagation() + + if (!isFolder && !isPlaceholder) { + onPreviewFile?.(node.data.id) + } + }} + onDragStart={event => { + if (isPlaceholder) { + event.preventDefault() + + return + } + + const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }]) + + event.dataTransfer.effectAllowed = 'copy' + event.dataTransfer.setData('application/x-hermes-paths', payload) + event.dataTransfer.setData('text/plain', node.data.id) + }} + ref={dragHandle} + style={style} + > + {isFolder && !isPlaceholder && ( + <span aria-hidden className="flex w-3 items-center justify-center"> + <Codicon + className="text-(--ui-text-tertiary)" + name={node.isOpen ? 'chevron-down' : 'chevron-right'} + size="0.75rem" + /> + </span> + )} + {!isFolder && <span aria-hidden className="w-3 shrink-0" />} + <span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)"> + {isPlaceholder ? ( + <Codicon name="loading" size="0.75rem" spinning /> + ) : isFolder ? ( + <Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" /> + ) : ( + <Codicon name="file" size="0.875rem" /> + )} + </span> + <span className="min-w-0 flex-1 truncate">{node.data.name}</span> + </div> + ) +} diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts new file mode 100644 index 000000000..a0ecd409f --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts @@ -0,0 +1,190 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { HermesReadDirResult } from '@/global' + +import { resetProjectTreeState, useProjectTree } from './use-project-tree' + +const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>() + +beforeEach(() => { + resetProjectTreeState() + readDir.mockReset() + ;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir } +}) + +afterEach(() => { + resetProjectTreeState() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop +}) + +function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult { + return { entries } +} + +describe('useProjectTree', () => { + it('starts empty when cwd is blank and skips IPC', async () => { + const { result } = renderHook(() => useProjectTree('')) + + await waitFor(() => expect(result.current.rootLoading).toBe(false)) + + expect(result.current.data).toEqual([]) + expect(result.current.rootError).toBeNull() + expect(readDir).not.toHaveBeenCalled() + }) + + it('loads root entries on mount and sorts folders before files', async () => { + readDir.mockResolvedValueOnce( + ok([ + { name: 'README.md', path: '/p/README.md', isDirectory: false }, + { name: 'src', path: '/p/src', isDirectory: true } + ]) + ) + + const { result } = renderHook(() => useProjectTree('/p')) + + await waitFor(() => expect(result.current.data.length).toBe(2)) + + expect(readDir).toHaveBeenCalledWith('/p') + // Hook trusts main-process sort order; folders/files preserved as supplied. + expect(result.current.data.map(n => n.name)).toEqual(['README.md', 'src']) + // Folder children start undefined (lazy load on first expand). + expect(result.current.data.find(n => n.name === 'src')?.children).toBeUndefined() + expect(result.current.data.find(n => n.name === 'src')?.isDirectory).toBe(true) + expect(result.current.data.find(n => n.name === 'README.md')?.isDirectory).toBe(false) + }) + + it('records rootError when readDir returns an error', async () => { + readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' }) + + const { result } = renderHook(() => useProjectTree('/locked')) + + await waitFor(() => expect(result.current.rootError).toBe('EACCES')) + expect(result.current.data).toEqual([]) + }) + + it('lazy-loads children on loadChildren and replaces the placeholder', async () => { + readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }])) + readDir.mockResolvedValueOnce( + ok([ + { name: 'index.ts', path: '/p/src/index.ts', isDirectory: false }, + { name: 'lib', path: '/p/src/lib', isDirectory: true } + ]) + ) + + const { result } = renderHook(() => useProjectTree('/p')) + + await waitFor(() => expect(result.current.data.length).toBe(1)) + + await act(async () => { + await result.current.loadChildren('/p/src') + }) + + const src = result.current.data[0] + expect(src.children?.map(n => n.name)).toEqual(['index.ts', 'lib']) + expect(src.loading).toBe(false) + expect(src.error).toBeUndefined() + }) + + it('keeps loaded tree state across remounts for the same cwd', async () => { + readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }])) + + const { result, unmount } = renderHook(() => useProjectTree('/p')) + + await waitFor(() => expect(result.current.data.length).toBe(1)) + + act(() => { + result.current.setNodeOpen('/p/src', true) + }) + + unmount() + + const remounted = renderHook(() => useProjectTree('/p')) + + expect(remounted.result.current.data.map(n => n.name)).toEqual(['src']) + expect(remounted.result.current.openState).toEqual({ '/p/src': true }) + expect(readDir).toHaveBeenCalledTimes(1) + }) + + it('captures per-folder error code and leaves the folder expandable but empty', async () => { + readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }])) + readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' }) + + const { result } = renderHook(() => useProjectTree('/p')) + + await waitFor(() => expect(result.current.data.length).toBe(1)) + + await act(async () => { + await result.current.loadChildren('/p/priv') + }) + + expect(result.current.data[0].error).toBe('EACCES') + expect(result.current.data[0].children).toEqual([]) + }) + + it('dedupes concurrent loadChildren calls for the same id', async () => { + readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }])) + + let resolveChildren: ((value: HermesReadDirResult) => void) | undefined + readDir.mockImplementationOnce( + () => + new Promise<HermesReadDirResult>(resolve => { + resolveChildren = resolve + }) + ) + + const { result } = renderHook(() => useProjectTree('/p')) + + await waitFor(() => expect(result.current.data.length).toBe(1)) + + await act(async () => { + // First call enters inflight, second short-circuits, third also short-circuits. + void result.current.loadChildren('/p/src') + void result.current.loadChildren('/p/src') + void result.current.loadChildren('/p/src') + resolveChildren?.(ok([{ name: 'a.ts', path: '/p/src/a.ts', isDirectory: false }])) + }) + + // Mount load + a single folder fetch — duplicates were dropped. + expect(readDir).toHaveBeenCalledTimes(2) + }) + + it('refreshRoot reloads the root and clears prior error', async () => { + readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' }) + readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }])) + + const { result } = renderHook(() => useProjectTree('/p')) + + await waitFor(() => expect(result.current.rootError).toBe('EACCES')) + + await act(async () => { + await result.current.refreshRoot() + }) + + expect(result.current.rootError).toBeNull() + expect(result.current.data.map(n => n.name)).toEqual(['README.md']) + }) + + it('reloads when cwd changes', async () => { + readDir.mockResolvedValueOnce(ok([{ name: 'one', path: '/a/one', isDirectory: false }])) + readDir.mockResolvedValueOnce(ok([{ name: 'two', path: '/b/two', isDirectory: false }])) + + const { rerender, result } = renderHook(({ cwd }) => useProjectTree(cwd), { initialProps: { cwd: '/a' } }) + + await waitFor(() => expect(result.current.data[0]?.name).toBe('one')) + + rerender({ cwd: '/b' }) + + await waitFor(() => expect(result.current.data[0]?.name).toBe('two')) + expect(readDir).toHaveBeenLastCalledWith('/b') + }) + + it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => { + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + + const { result } = renderHook(() => useProjectTree('/p')) + + await waitFor(() => expect(result.current.rootError).toBe('no-bridge')) + expect(result.current.data).toEqual([]) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts new file mode 100644 index 000000000..229bfcacd --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -0,0 +1,245 @@ +import { useStore } from '@nanostores/react' +import { atom } from 'nanostores' +import { useCallback, useEffect, useMemo } from 'react' + +import { clearProjectDirCache, readProjectDir } from './ipc' + +export interface TreeNode { + /** Absolute filesystem path. Doubles as react-arborist node id. */ + id: string + name: string + /** Drives arborist's leaf-vs-expandable decision via childrenAccessor. */ + isDirectory: boolean + /** `undefined` = directory, children not yet loaded. `[]` = loaded empty. */ + children?: TreeNode[] + /** True while a readDir for this folder is in flight. */ + loading?: boolean + /** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */ + error?: string +} + +const PLACEHOLDER_ID = '__loading__' + +function makeNode(path: string, name: string, isDirectory: boolean): TreeNode { + return { id: path, isDirectory, name } +} + +function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] { + if (!nodes) { + return [] + } + + return nodes.map(n => { + if (n.id === id) { + return patch(n) + } + + if (n.children && n.children.length > 0) { + return { ...n, children: patchNode(n.children, id, patch) } + } + + return n + }) +} + +function placeholderChild(parentId: string): TreeNode { + return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' } +} + +export interface UseProjectTreeResult { + data: TreeNode[] + openState: Record<string, boolean> + rootError: string | null + rootLoading: boolean + loadChildren: (id: string) => Promise<void> + refreshRoot: () => Promise<void> + setNodeOpen: (id: string, open: boolean) => void +} + +interface ProjectTreeState { + cwd: string + data: TreeNode[] + loaded: boolean + openState: Record<string, boolean> + requestId: number + rootError: string | null + rootLoading: boolean +} + +const initialState: ProjectTreeState = { + cwd: '', + data: [], + loaded: false, + openState: {}, + requestId: 0, + rootError: null, + rootLoading: false +} + +const inflight = new Set<string>() +const $projectTree = atom<ProjectTreeState>(initialState) +let nextRootRequestId = 0 + +function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) { + $projectTree.set(updater($projectTree.get())) +} + +function clearProjectTree() { + nextRootRequestId += 1 + inflight.clear() + $projectTree.set({ ...initialState, requestId: nextRootRequestId }) +} + +async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) { + if (!cwd) { + clearProjectTree() + + return + } + + const current = $projectTree.get() + + if (!force && current.cwd === cwd && (current.loaded || current.rootLoading)) { + return + } + + const requestId = nextRootRequestId + 1 + nextRootRequestId = requestId + inflight.clear() + + if (force || current.cwd !== cwd) { + clearProjectDirCache(cwd) + } + + $projectTree.set({ + cwd, + data: [], + loaded: false, + openState: current.cwd === cwd ? current.openState : {}, + requestId, + rootError: null, + rootLoading: true + }) + + const { entries, error } = await readProjectDir(cwd, cwd) + + setProjectTree(latest => { + if (latest.cwd !== cwd || latest.requestId !== requestId) { + return latest + } + + return { + ...latest, + data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)), + loaded: true, + rootError: error || null, + rootLoading: false + } + }) +} + +export function resetProjectTreeState() { + clearProjectTree() + clearProjectDirCache() +} + +/** + * Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first + * expand and cached in this feature-owned atom so unrelated chat rerenders or + * remounts cannot reset the browser. A placeholder leaf renders so the + * disclosure caret shows for unloaded folders. `refreshRoot` invalidates the + * whole tree (used after cwd change or manual refresh). + */ +export function useProjectTree(cwd: string): UseProjectTreeResult { + const state = useStore($projectTree) + + const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd]) + + const setNodeOpen = useCallback( + (id: string, open: boolean) => { + setProjectTree(current => { + if (current.cwd !== cwd || current.openState[id] === open) { + return current + } + + return { + ...current, + openState: { + ...current.openState, + [id]: open + } + } + }) + }, + [cwd] + ) + + const loadChildren = useCallback( + async (id: string) => { + if (!cwd || inflight.has(id)) { + return + } + + inflight.add(id) + + setProjectTree(current => { + if (current.cwd !== cwd) { + return current + } + + return { + ...current, + data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] })) + } + }) + + const { entries, error } = await readProjectDir(id, cwd) + + inflight.delete(id) + + setProjectTree(current => { + if (current.cwd !== cwd) { + return current + } + + return { + ...current, + data: patchNode(current.data, id, n => ({ + ...n, + loading: false, + error: error || undefined, + children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)) + })) + } + }) + }, + [cwd] + ) + + useEffect(() => { + void loadRoot(cwd) + }, [cwd]) + + return useMemo( + () => ({ + data: state.cwd === cwd ? state.data : [], + loadChildren, + openState: state.cwd === cwd ? state.openState : {}, + refreshRoot, + rootError: state.cwd === cwd ? state.rootError : null, + rootLoading: state.cwd === cwd ? state.rootLoading : Boolean(cwd), + setNodeOpen + }), + [ + cwd, + loadChildren, + refreshRoot, + setNodeOpen, + state.cwd, + state.data, + state.openState, + state.rootError, + state.rootLoading + ] + ) +} diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx new file mode 100644 index 000000000..02c9708ed --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -0,0 +1,304 @@ +import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Loader } from '@/components/ui/loader' +import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' +import { setCurrentSessionPreviewTarget } from '@/store/preview' +import { $currentBranch, $currentCwd } from '@/store/session' + +import { SidebarPanelLabel } from '../shell/sidebar-label' + +import { ProjectTree } from './files/tree' +import { useProjectTree } from './files/use-project-tree' +import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store' +import { TerminalSlot } from './terminal/persistent' + +interface RightSidebarPaneProps { + onActivateFile: (path: string) => void + onActivateFolder: (path: string) => void + onChangeCwd: (path: string) => Promise<void> | void +} + +interface RightSidebarTab { + icon: string + id: RightSidebarTabId + label: string +} + +const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [ + { id: 'files', label: 'File system', icon: 'files' }, + { id: 'terminal', label: 'Terminal', icon: 'terminal' } +] + +export function RightSidebarPane({ + onActivateFile, + onActivateFolder, + onChangeCwd +}: RightSidebarPaneProps) { + const activeTab = useStore($rightSidebarTab) + const terminalTakeover = useStore($terminalTakeover) + const currentBranch = useStore($currentBranch).trim() + const currentCwd = useStore($currentCwd).trim() + const hasCwd = currentCwd.length > 0 + + const cwdName = hasCwd + ? (currentCwd + .split(/[\\/]+/) + .filter(Boolean) + .pop() ?? currentCwd) + : 'No folder selected' + + const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd) + const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab + + const chooseFolder = async () => { + const selected = await window.hermesDesktop?.selectPaths({ + defaultPath: hasCwd ? currentCwd : undefined, + directories: true, + multiple: false, + title: 'Change working directory' + }) + + if (selected?.[0]) { + await onChangeCwd(selected[0]) + } + } + + const previewFile = async (path: string) => { + try { + const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined) + + if (!preview) { + throw new Error(`Could not preview ${path}`) + } + + setCurrentSessionPreviewTarget(preview, 'file-browser', path) + } catch (error) { + notifyError(error, 'Preview unavailable') + } + } + + const tabs = terminalTakeover + ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') + : RIGHT_SIDEBAR_TABS + + return ( + <aside + aria-label="Right sidebar" + className="before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)] before:absolute before:inset-x-0 before:top-(--titlebar-height) before:z-1 before:h-px before:bg-(--ui-stroke-tertiary)" + > + <RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} /> + + {effectiveTab === 'terminal' ? ( + <TerminalSlot /> + ) : ( + <FilesystemTab + cwd={currentCwd} + cwdName={cwdName} + data={data} + error={rootError} + hasCwd={hasCwd} + loading={rootLoading} + onActivateFile={onActivateFile} + onActivateFolder={onActivateFolder} + onChangeFolder={chooseFolder} + onLoadChildren={loadChildren} + onNodeOpenChange={setNodeOpen} + onPreviewFile={previewFile} + onRefresh={() => void refreshRoot()} + openState={openState} + /> + )} + </aside> + ) +} + +function RightSidebarChrome({ + activeTab, + branch, + tabs +}: { + activeTab: RightSidebarTabId + branch: string + tabs: readonly RightSidebarTab[] +}) { + return ( + <header className="shrink-0 bg-transparent text-[0.75rem]"> + <div className="flex items-center gap-2 border-b border-(--ui-stroke-tertiary) px-2.5 py-1"> + <nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1"> + {tabs.map(tab => ( + <button + aria-label={tab.label} + aria-pressed={tab.id === activeTab} + className={cn( + 'grid size-6 shrink-0 place-items-center rounded-lg text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-(--ui-control-active-background) active:text-foreground', + 'data-[active=true]:bg-(--ui-control-active-background) data-[active=true]:text-foreground' + )} + data-active={tab.id === activeTab} + key={tab.id} + onClick={() => setRightSidebarTab(tab.id)} + title={tab.label} + type="button" + > + <Codicon name={tab.icon} size="0.875rem" /> + </button> + ))} + </nav> + {branch && ( + <span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)"> + <Codicon className="shrink-0" name="git-branch" size="0.75rem" /> + <span className="truncate">{branch}</span> + </span> + )} + </div> + </header> + ) +} + +interface FilesystemTabProps extends FileTreeBodyProps { + cwdName: string + hasCwd: boolean + onChangeFolder: () => Promise<void> | void + onRefresh: () => void +} + +function FilesystemTab({ + cwd, + cwdName, + data, + error, + hasCwd, + loading, + onActivateFile, + onActivateFolder, + onChangeFolder, + onLoadChildren, + onNodeOpenChange, + onPreviewFile, + onRefresh, + openState +}: FilesystemTabProps) { + return ( + <div className="group/project-header flex min-h-0 flex-1 flex-col"> + <RightSidebarSectionHeader> + <button + className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)" + onClick={() => void onChangeFolder()} + title={hasCwd ? cwd : 'No folder selected'} + type="button" + > + <SidebarPanelLabel>{cwdName}</SidebarPanelLabel> + </button> + <Button + aria-label="Refresh tree" + className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100" + disabled={!hasCwd || loading} + onClick={onRefresh} + size="icon" + title="Refresh tree" + variant="ghost" + > + <Codicon name="refresh" size="0.8125rem" spinning={loading} /> + </Button> + </RightSidebarSectionHeader> + <FileTreeBody + cwd={cwd} + data={data} + error={error} + loading={loading} + onActivateFile={onActivateFile} + onActivateFolder={onActivateFolder} + onLoadChildren={onLoadChildren} + onNodeOpenChange={onNodeOpenChange} + onPreviewFile={onPreviewFile} + openState={openState} + /> + </div> + ) +} + +export function RightSidebarSectionHeader({ children }: { children: ReactNode }) { + return <div className="flex h-7 shrink-0 items-center px-2">{children}</div> +} + +interface FileTreeBodyProps { + cwd: string + data: ReturnType<typeof useProjectTree>['data'] + error: string | null + loading: boolean + onActivateFile: (path: string) => void + onActivateFolder: (path: string) => void + onLoadChildren: (id: string) => void | Promise<void> + onNodeOpenChange: (id: string, open: boolean) => void + onPreviewFile?: (path: string) => void + openState: ReturnType<typeof useProjectTree>['openState'] +} + +function FileTreeBody({ + cwd, + data, + error, + loading, + onActivateFile, + onActivateFolder, + onLoadChildren, + onNodeOpenChange, + onPreviewFile, + openState +}: FileTreeBodyProps) { + if (!cwd) { + return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" /> + } + + if (error) { + return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" /> + } + + if (loading && data.length === 0) { + return <FileTreeLoadingState /> + } + + if (data.length === 0) { + return <EmptyState body="This folder is empty." title="Empty" /> + } + + return ( + <ProjectTree + data={data} + onActivateFile={onActivateFile} + onActivateFolder={onActivateFolder} + onLoadChildren={onLoadChildren} + onNodeOpenChange={onNodeOpenChange} + onPreviewFile={onPreviewFile} + openState={openState} + /> + ) +} + +function FileTreeLoadingState() { + return ( + <div aria-label="Loading file tree" className="grid min-h-0 flex-1 place-items-center px-3" role="status"> + <Loader + aria-hidden="true" + className="size-8 text-(--ui-text-tertiary)" + pathSteps={180} + role="presentation" + strokeScale={0.68} + type="spiral-search" + /> + </div> + ) +} + +function EmptyState({ body, title }: { body: string; title: string }) { + return ( + <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center"> + <div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div> + <div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div> + </div> + ) +} diff --git a/apps/desktop/src/app/right-sidebar/store.ts b/apps/desktop/src/app/right-sidebar/store.ts new file mode 100644 index 000000000..a560bfdda --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/store.ts @@ -0,0 +1,15 @@ +import { atom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web' + +const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover' + +export const $rightSidebarTab = atom<RightSidebarTabId>('files') +export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false)) + +$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active)) + +export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab) +export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active) diff --git a/apps/desktop/src/app/right-sidebar/terminal/index.tsx b/apps/desktop/src/app/right-sidebar/terminal/index.tsx new file mode 100644 index 000000000..9e063ccac --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/index.tsx @@ -0,0 +1,93 @@ +import '@xterm/xterm/css/xterm.css' + +import { useStore } from '@nanostores/react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Loader } from '@/components/ui/loader' + +import { SidebarPanelLabel } from '../../shell/sidebar-label' +import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store' + +import { addSelectionShortcutLabel } from './selection' +import { useTerminalSession } from './use-terminal-session' + +interface TerminalTabProps { + cwd: string + onAddSelectionToChat: (text: string, label?: string) => void +} + +export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) { + const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({ + cwd, + onAddSelectionToChat + }) + + const takeover = useStore($terminalTakeover) + const label = takeover ? 'Return to split view' : 'Focus terminal view' + + const toggleTakeover = () => { + // Pre-select the Terminal tab so the slot is ready to host us on return. + if (takeover) { + setRightSidebarTab('terminal') + } + setTerminalTakeover(!takeover) + } + + return ( + <div className="relative flex min-h-0 min-w-0 flex-1 flex-col"> + <div className="flex h-8 shrink-0 items-center gap-2 px-2.5"> + <SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel> + <Button + aria-label={label} + className="ml-auto size-6 rounded-md text-white!" + onClick={toggleTakeover} + size="icon" + title={label} + type="button" + variant="ghost" + > + <Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" /> + </Button> + </div> + <div className="relative min-h-0 flex-1 bg-[#002b36] p-2"> + {status === 'starting' && ( + <div className="pointer-events-none absolute inset-0 z-10 grid place-items-center"> + <Loader + className="size-8 text-(--ui-text-tertiary)" + pathSteps={180} + strokeScale={0.68} + type="spiral-search" + /> + </div> + )} + {selection.trim() && ( + <div className="absolute z-50 flex items-center gap-1" style={selectionStyle ?? { right: 12, top: 8 }}> + <Button + className="h-6 rounded-md px-2 text-[0.68rem] shadow-md backdrop-blur-md" + onClick={event => event.preventDefault()} + onMouseDown={event => { + event.preventDefault() + event.stopPropagation() + addSelectionToChat() + }} + type="button" + variant="secondary" + > + Add to chat + <span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span> + </Button> + </div> + )} + {/* Outer div paints the dark inset; inner div is the xterm host so the + canvas sizes to the *content* area and p-2 shows as terminal padding. + Forcing screen/viewport bg avoids xterm's default black peeking + through the unused pixels below the last full row. */} + <div + className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!" + ref={hostRef} + /> + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx new file mode 100644 index 000000000..11ba30953 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -0,0 +1,110 @@ +import { useStore } from '@nanostores/react' +import { atom } from 'nanostores' +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react' + +import { TerminalTab } from './index' +import { TERMINAL_BG } from './selection' + +/** + * One xterm Terminal mounted at the layout root and CSS-overlayed onto + * whichever `<TerminalSlot />` is active. Moving the host DOM detaches xterm's + * WebGL renderer (it observes its own attachment) and resets the screen, so + * the host stays put and we chase the slot's bounding rect with position:fixed. + */ + +const $slot = atom<HTMLElement | null>(null) + +const SLOT_CLASS = 'relative flex min-h-0 min-w-0 flex-1 flex-col' + +export function TerminalSlot({ className = SLOT_CLASS }: { className?: string }) { + const ref = useRef<HTMLDivElement | null>(null) + + useEffect(() => { + const el = ref.current + if (!el) return + + $slot.set(el) + return () => { + if ($slot.get() === el) $slot.set(null) + } + }, []) + + return <div className={className} ref={ref} /> +} + +interface PersistentTerminalProps { + cwd: string + onAddSelectionToChat: (text: string, label?: string) => void +} + +interface Rect { + top: number + left: number + width: number + height: number +} + +const sameRect = (a: Rect | null, b: Rect) => + !!a && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height + +export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerminalProps) { + const slot = useStore($slot) + const [rect, setRect] = useState<Rect | null>(null) + const [ready, setReady] = useState(false) + + useLayoutEffect(() => { + if (!slot) { + setRect(null) + return + } + + let prev: Rect | null = null + let frame = 0 + + const tick = () => { + const r = slot.getBoundingClientRect() + // floor top/left + ceil right/bottom: overlay always covers the slot's + // full pixel footprint, so half-pixel rects can't leak page bg through. + const top = Math.floor(r.top) + const left = Math.floor(r.left) + const next: Rect = { top, left, width: Math.ceil(r.right) - left, height: Math.ceil(r.bottom) - top } + + if (!sameRect(prev, next)) { + prev = next + setRect(next) + if (next.width > 0 && next.height > 0) setReady(true) + } + + frame = requestAnimationFrame(tick) + } + + tick() + return () => cancelAnimationFrame(frame) + }, [slot]) + + const visible = Boolean(rect && rect.width > 0 && rect.height > 0) + + const style: CSSProperties = { + position: 'fixed', + top: rect?.top ?? 0, + left: rect?.left ?? 0, + width: rect?.width ?? 0, + height: rect?.height ?? 0, + display: 'flex', + flexDirection: 'column', + visibility: visible ? 'visible' : 'hidden', + pointerEvents: visible ? 'auto' : 'none', + zIndex: 4, + backgroundColor: TERMINAL_BG, + contain: 'layout size paint' + } + + // Defer mount until real dims — booting xterm at 0×0 starts the shell at + // 80×24, then the first ResizeObserver SIGWINCH redraws the prompt on a + // new line. After first measurement we keep it mounted forever. + return ( + <div aria-hidden={!visible} style={style}> + {ready && <TerminalTab cwd={cwd} onAddSelectionToChat={onAddSelectionToChat} />} + </div> + ) +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/selection.ts b/apps/desktop/src/app/right-sidebar/terminal/selection.ts new file mode 100644 index 000000000..4f0049be8 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/selection.ts @@ -0,0 +1,75 @@ +import type { ITheme, Terminal } from '@xterm/xterm' +import type { CSSProperties } from 'react' + +// Solarized-derived palette, but with bright ANSI 8–15 promoted to real +// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold, +// crimson, ...) emit bright SGR codes that would otherwise wash out to gray. +// We always render the dark canvas — the app's light surfaces can't host the +// default skin without dropping below readable contrast. +export const TERMINAL_BG = '#002b36' + +const THEME: ITheme = { + background: TERMINAL_BG, + foreground: '#839496', + cursor: '#93a1a1', + cursorAccent: TERMINAL_BG, + selectionBackground: '#586e7555', + black: '#073642', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#586e75', + brightRed: '#f25c54', + brightGreen: '#b3d437', + brightYellow: '#f7c948', + brightBlue: '#5fb3ff', + brightMagenta: '#ff6ab4', + brightCyan: '#5cd9c8', + brightWhite: '#fdf6e3' +} + +export const terminalTheme = (): ITheme => THEME + +export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac') + +export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L') + +export function isAddSelectionShortcut(event: KeyboardEvent) { + const mod = isMacPlatform() ? event.metaKey : event.ctrlKey + + return mod && !event.shiftKey && event.key.toLowerCase() === 'l' +} + +export function terminalSelectionLabel(term: Terminal, shellName: string, text: string) { + const pos = term.getSelectionPosition() + + if (pos) { + return pos.start.y === pos.end.y ? `${shellName}:${pos.start.y}` : `${shellName}:${pos.start.y}-${pos.end.y}` + } + + const lines = Math.max(1, text.trim().split(/\r?\n/).length) + + return `${shellName}:${lines} line${lines === 1 ? '' : 's'}` +} + +export function terminalSelectionAnchor(host: HTMLDivElement): CSSProperties | null { + const rect = Array.from(host.querySelectorAll<HTMLElement>('.xterm-selection div')) + .map(node => node.getBoundingClientRect()) + .filter(r => r.width > 0 && r.height > 0) + .at(-1) + + if (!rect) { + return null + } + + const hostRect = host.getBoundingClientRect() + const buttonWidth = 128 + const left = Math.min(Math.max(rect.left - hostRect.left, 8), Math.max(8, host.clientWidth - buttonWidth - 8)) + const top = Math.min(Math.max(rect.bottom - hostRect.top + 4, 8), Math.max(8, host.clientHeight - 34)) + + return { left, top } +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts new file mode 100644 index 000000000..6375f440e --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -0,0 +1,448 @@ +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 { useCallback, useEffect, useRef, useState } from 'react' +import type { CSSProperties } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection' + +type TerminalStatus = 'closed' | 'open' | 'starting' + +const HERMES_PATHS_MIME = 'application/x-hermes-paths' + +function readEscapeSequence(data: string, index: number) { + if (data.charCodeAt(index) !== 0x1b || index + 1 >= data.length) { + return null + } + + const kind = data[index + 1] + + if (kind === '[') { + for (let i = index + 2; i < data.length; i += 1) { + const code = data.charCodeAt(i) + + if (code >= 0x40 && code <= 0x7e) { + return data.slice(index, i + 1) + } + } + } + + if (kind === ']') { + for (let i = index + 2; i < data.length; i += 1) { + if (data.charCodeAt(i) === 0x07) { + return data.slice(index, i + 1) + } + + if (data.charCodeAt(i) === 0x1b && data[i + 1] === '\\') { + return data.slice(index, i + 2) + } + } + } + + return data.slice(index, Math.min(index + 2, data.length)) +} + +function stripEscapeSequences(data: string) { + let index = 0 + let text = '' + + while (index < data.length) { + const sequence = readEscapeSequence(data, index) + + if (sequence) { + index += sequence.length + } else { + text += data[index] + index += 1 + } + } + + return text +} + +function isStartupSpacer(data: string) { + const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '') + + return text === '' || text === '%' +} + +function stripInitialPromptGap(data: string) { + let index = 0 + let prefix = '' + + while (index < data.length) { + const sequence = readEscapeSequence(data, index) + + if (sequence) { + prefix += sequence + index += sequence.length + } else if (data[index] === '\r' || data[index] === '\n') { + index += 1 + } else { + return prefix + data.slice(index) + } + } + + return prefix +} + +interface UseTerminalSessionOptions { + cwd: string + onAddSelectionToChat: (text: string, label?: string) => void +} + +function transferHasDropCandidates(t: DataTransfer): boolean { + if (t.types?.includes(HERMES_PATHS_MIME)) return true + if ((t.files?.length ?? 0) > 0) return true + + for (let i = 0; i < (t.items?.length ?? 0); i += 1) { + if (t.items[i]?.kind === 'file') return true + } + + return false +} + +function collectDroppedPaths(t: DataTransfer): string[] { + const seen = new Set<string>() + const push = (value: unknown) => { + if (typeof value !== 'string') return + const path = value.trim() + if (path) seen.add(path) + } + + try { + const raw = t.getData(HERMES_PATHS_MIME) + if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path) + } catch { + // Malformed in-app drag payload — fall through to OS files. + } + + const getPath = window.hermesDesktop?.getPathForFile + const addFile = (file: File | null) => { + if (!file || !getPath) return + try { + push(getPath(file)) + } catch { + // File handle unavailable. + } + } + + for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i)) + for (let i = 0; i < (t.items?.length ?? 0); i += 1) { + const item = t.items[i] + if (item?.kind === 'file') addFile(item.getAsFile()) + } + + return [...seen] +} + +function quotePathForShell(path: string, shellName: string): string { + const shell = shellName.toLowerCase() + if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'` + if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"` + return `'${path.replace(/'/g, "'\\''")}'` +} + +export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) { + const hostRef = useRef<HTMLDivElement | null>(null) + const termRef = useRef<Terminal | null>(null) + const sessionIdRef = useRef<string | null>(null) + const shellNameRef = useRef('shell') + const selectionLabelRef = useRef('') + const selectionRef = useRef('') + const onAddSelectionToChatRef = useRef(onAddSelectionToChat) + const [status, setStatus] = useState<TerminalStatus>('starting') + const [selection, setSelection] = useState('') + const [selectionStyle, setSelectionStyle] = useState<CSSProperties | null>(null) + const [shellName, setShellName] = useState('shell') + + useEffect(() => { + onAddSelectionToChatRef.current = onAddSelectionToChat + }, [onAddSelectionToChat]) + + const addSelectionToChat = useCallback(() => { + const selectedText = selectionRef.current || termRef.current?.getSelection() || '' + + const label = + selectionLabelRef.current || + (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection') + + const trimmed = selectedText.trim() + + if (!trimmed) { + return + } + + onAddSelectionToChatRef.current(trimmed, label) + termRef.current?.clearSelection() + selectionRef.current = '' + selectionLabelRef.current = '' + setSelection('') + setSelectionStyle(null) + triggerHaptic('selection') + }, []) + + useEffect(() => { + if (!selection.trim()) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (!isAddSelectionShortcut(event)) { + return + } + + event.preventDefault() + event.stopPropagation() + addSelectionToChat() + } + + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [addSelectionToChat, selection]) + + useEffect(() => { + const host = hostRef.current + const terminalApi = window.hermesDesktop?.terminal + + if (!host || !terminalApi) { + setStatus('closed') + + return + } + + let disposed = false + const cleanup: Array<() => void> = [] + let lastSentSize: { cols: number; rows: number } | null = null + + const term = new Terminal({ + allowProposedApi: true, + allowTransparency: true, + convertEol: true, + cursorBlink: true, + fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace", + fontSize: 11, + lineHeight: 1.12, + macOptionIsMeta: true, + scrollback: 1000, + theme: terminalTheme() + }) + + const fit = new FitAddon() + + termRef.current = term + term.loadAddon(fit) + term.loadAddon(new Unicode11Addon()) + term.loadAddon(new WebLinksAddon()) + term.unicode.activeVersion = '11' + term.open(host) + term.focus() + + // WebGL renderer matches the dashboard ChatPage path; xterm's default DOM + // renderer paints SGR via CSS classes that visibly mute against our skins. + try { + const webgl = new WebglAddon() + webgl.onContextLoss(() => webgl.dispose()) + term.loadAddon(webgl) + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err) + } + + const onDragOver = (e: DragEvent) => { + if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + } + + const onDrop = (e: DragEvent) => { + const id = sessionIdRef.current + if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + const paths = collectDroppedPaths(e.dataTransfer) + if (!paths.length) return + void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `) + term.focus() + triggerHaptic('selection') + } + + host.addEventListener('dragenter', onDragOver) + host.addEventListener('dragover', onDragOver) + host.addEventListener('drop', onDrop) + cleanup.push(() => { + host.removeEventListener('dragenter', onDragOver) + host.removeEventListener('dragover', onDragOver) + host.removeEventListener('drop', onDrop) + }) + + const fitAndResize = () => { + if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) { + return + } + + try { + fit.fit() + } catch { + return + } + + const id = sessionIdRef.current + + if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) { + lastSentSize = { cols: term.cols, rows: term.rows } + void terminalApi.resize(id, { cols: term.cols, rows: term.rows }) + } + } + + // Coalesce ResizeObserver bursts through rAF — running fit.fit() + // synchronously while sibling panes are mid-transition (e.g. file browser + // collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild. + let pendingFrame = 0 + const scheduleResize = () => { + if (pendingFrame) return + pendingFrame = window.requestAnimationFrame(() => { + pendingFrame = 0 + if (!disposed) fitAndResize() + }) + } + + const resizeObserver = new ResizeObserver(scheduleResize) + resizeObserver.observe(host) + cleanup.push(() => { + resizeObserver.disconnect() + if (pendingFrame) window.cancelAnimationFrame(pendingFrame) + }) + + const dataDisposable = term.onData(data => { + const id = sessionIdRef.current + + if (id) { + void terminalApi.write(id, data) + } + }) + + cleanup.push(() => dataDisposable.dispose()) + + const selectionDisposable = term.onSelectionChange(() => { + const next = term.getSelection() + selectionRef.current = next + selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : '' + setSelection(next) + setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null) + }) + + cleanup.push(() => selectionDisposable.dispose()) + + term.attachCustomKeyEventHandler(event => { + if (event.type !== 'keydown') { + return true + } + + if (isAddSelectionShortcut(event) && term.hasSelection()) { + event.preventDefault() + addSelectionToChat() + + return false + } + + return true + }) + + fitAndResize() + + void terminalApi + .start({ cols: term.cols, cwd, rows: term.rows }) + .then(session => { + if (disposed) { + void terminalApi.dispose(session.id) + + return + } + + sessionIdRef.current = session.id + lastSentSize = { cols: term.cols, rows: term.rows } + shellNameRef.current = session.shell || 'shell' + setShellName(session.shell || 'shell') + + if (term.hasSelection()) { + const currentSelection = term.getSelection() + selectionRef.current = currentSelection + selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection) + } else { + selectionRef.current = '' + selectionLabelRef.current = '' + } + + setStatus('open') + let wrotePromptContent = false + + cleanup.push( + terminalApi.onData(session.id, data => { + if (wrotePromptContent) { + term.write(data) + + return + } + + if (isStartupSpacer(data)) { + return + } + + const next = stripInitialPromptGap(data) + + if (next) { + wrotePromptContent = true + term.write(next) + } + }), + terminalApi.onExit(session.id, sessionExit => { + const { code, signal } = sessionExit + setStatus('closed') + term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`) + }) + ) + window.requestAnimationFrame(() => { + fitAndResize() + term.focus() + }) + }) + .catch(error => { + setStatus('closed') + term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`) + }) + + return () => { + disposed = true + cleanup.forEach(run => run()) + + const id = sessionIdRef.current + sessionIdRef.current = null + + if (id) { + void terminalApi.dispose(id) + } + + term.dispose() + termRef.current = null + shellNameRef.current = 'shell' + selectionRef.current = '' + selectionLabelRef.current = '' + } + }, [addSelectionToChat, cwd]) + + return { + addSelectionToChat, + hostRef, + selection, + selectionStyle, + shellName, + status + } +} diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts new file mode 100644 index 000000000..8de358dba --- /dev/null +++ b/apps/desktop/src/app/routes.ts @@ -0,0 +1,79 @@ +export const SESSION_ROUTE_PREFIX = '/' +export const NEW_CHAT_ROUTE = '/' +export const SETTINGS_ROUTE = '/settings' +export const COMMAND_CENTER_ROUTE = '/command-center' +export const SKILLS_ROUTE = '/skills' +export const MESSAGING_ROUTE = '/messaging' +export const ARTIFACTS_ROUTE = '/artifacts' +export const CRON_ROUTE = '/cron' +export const PROFILES_ROUTE = '/profiles' +export const AGENTS_ROUTE = '/agents' + +export type AppView = + | 'agents' + | 'artifacts' + | 'chat' + | 'command-center' + | 'cron' + | 'messaging' + | 'profiles' + | 'settings' + | 'skills' + +export type AppRouteId = + | 'agents' + | 'artifacts' + | 'command-center' + | 'cron' + | 'messaging' + | 'new' + | 'profiles' + | 'settings' + | 'skills' + +export interface AppRoute { + id: AppRouteId + path: string + view: AppView +} + +export const APP_ROUTES = [ + { id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' }, + { id: 'settings', path: SETTINGS_ROUTE, view: 'settings' }, + { id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' }, + { id: 'skills', path: SKILLS_ROUTE, view: 'skills' }, + { id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' }, + { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, + { id: 'cron', path: CRON_ROUTE, view: 'cron' }, + { id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' }, + { id: 'agents', path: AGENTS_ROUTE, view: 'agents' } +] as const satisfies readonly AppRoute[] + +const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view])) +const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path)) + +export function isNewChatRoute(pathname: string): boolean { + return pathname === NEW_CHAT_ROUTE +} + +export function routeSessionId(pathname: string): string | null { + if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) { + return null + } + + const id = pathname.slice(SESSION_ROUTE_PREFIX.length) + + return id && !id.includes('/') ? decodeURIComponent(id) : null +} + +export function sessionRoute(sessionId: string): string { + return `${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}` +} + +export function appViewForPath(pathname: string): AppView { + if (isNewChatRoute(pathname) || routeSessionId(pathname)) { + return 'chat' + } + + return APP_VIEW_BY_PATH.get(pathname) ?? 'chat' +} diff --git a/apps/desktop/src/app/session/hooks/use-context-suggestions.ts b/apps/desktop/src/app/session/hooks/use-context-suggestions.ts new file mode 100644 index 000000000..b1e1b8878 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-context-suggestions.ts @@ -0,0 +1,58 @@ +import { type MutableRefObject, useCallback, useEffect } from 'react' + +import { $currentCwd, setContextSuggestions } from '@/store/session' + +import type { ContextSuggestion } from '../../types' + +interface ContextSuggestionsOptions { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject<string | null> + currentCwd: string + gatewayState: string | undefined + requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> +} + +export function useContextSuggestions({ + activeSessionId, + activeSessionIdRef, + currentCwd, + gatewayState, + requestGateway +}: ContextSuggestionsOptions) { + const refresh = useCallback(async () => { + if (!activeSessionId) { + setContextSuggestions([]) + + return + } + + const sessionId = activeSessionId + const cwd = currentCwd || '' + + // Race guard: only commit if the session+cwd we sent for still match + // by the time the gateway responds. + const stillCurrent = () => activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd + + try { + const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', { + session_id: sessionId, + word: '@file:', + cwd: cwd || undefined + }) + + if (stillCurrent()) { + setContextSuggestions((result.items || []).filter(i => i.text)) + } + } catch { + if (stillCurrent()) { + setContextSuggestions([]) + } + } + }, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway]) + + useEffect(() => { + if (gatewayState === 'open' && activeSessionId) { + void refresh() + } + }, [activeSessionId, gatewayState, refresh]) +} diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts new file mode 100644 index 000000000..b1122d1c5 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -0,0 +1,99 @@ +import { type MutableRefObject, useCallback } from 'react' + +import { notify, notifyError } from '@/store/notifications' +import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session' +import type { SessionRuntimeInfo } from '@/types/hermes' + +interface CwdActionsOptions { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject<string | null> + onSessionRuntimeInfo?: (info: Pick<SessionRuntimeInfo, 'branch' | 'cwd'>) => void + requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> +} + +export function useCwdActions({ + activeSessionId, + activeSessionIdRef, + onSessionRuntimeInfo, + requestGateway +}: CwdActionsOptions) { + const refreshProjectBranch = useCallback( + async (cwd: string) => { + const target = cwd.trim() + + if (!target || activeSessionIdRef.current) { + return + } + + try { + const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { + key: 'project', + cwd: target + }) + + if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) { + setCurrentBranch(info.branch || '') + } + } catch { + setCurrentBranch('') + } + }, + [activeSessionIdRef, requestGateway] + ) + + const changeSessionCwd = useCallback( + async (cwd: string) => { + const trimmed = cwd.trim() + + if (!trimmed) { + return + } + + if (!activeSessionId) { + try { + const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { + key: 'project', + cwd: trimmed + }) + + setCurrentCwd(info.cwd || trimmed) + setCurrentBranch(info.branch || '') + } catch (err) { + notifyError(err, 'Working directory change failed') + } + + return + } + + try { + const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', { + session_id: activeSessionId, + cwd: trimmed + }) + + setCurrentCwd(info.cwd || trimmed) + setCurrentBranch(info.branch || '') + onSessionRuntimeInfo?.({ branch: info.branch || '', cwd: info.cwd || trimmed }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + + if (!message.includes('unknown method')) { + notifyError(err, 'Working directory change failed') + + return + } + + setCurrentCwd(trimmed) + setCurrentBranch('') + notify({ + kind: 'warning', + title: 'Working directory staged', + message: 'Restart the desktop backend to apply cwd changes to this active session.' + }) + } + }, + [activeSessionId, onSessionRuntimeInfo, requestGateway] + ) + + return { changeSessionCwd, refreshProjectBranch } +} diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts new file mode 100644 index 000000000..59406c8df --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -0,0 +1,74 @@ +import { type MutableRefObject, useCallback, useState } from 'react' + +import { getHermesConfig, getHermesConfigDefaults } from '@/hermes' +import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime' +import { + $currentCwd, + setAvailablePersonalities, + setCurrentCwd, + setCurrentFastMode, + setCurrentPersonality, + setCurrentReasoningEffort, + setCurrentServiceTier, + setIntroPersonality +} from '@/store/session' + +const DEFAULT_VOICE_SECONDS = 120 +const FAST_TIERS = new Set(['fast', 'priority', 'on']) + +function recordingLimit(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_SECONDS +} + +interface HermesConfigOptions { + activeSessionIdRef: MutableRefObject<string | null> + refreshProjectBranch: (cwd: string) => Promise<void> +} + +export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) { + const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS) + const [sttEnabled, setSttEnabled] = useState(true) + + const refreshHermesConfig = useCallback(async () => { + try { + const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))]) + + const personality = normalizePersonalityValue( + typeof config.display?.personality === 'string' ? config.display.personality : '' + ) + + setIntroPersonality(personality) + // Active sessions keep their per-session value; standalone falls back to config. + setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality)) + setAvailablePersonalities([ + ...new Set([ + 'none', + ...BUILTIN_PERSONALITIES, + ...personalityNamesFromConfig(defaults), + ...personalityNamesFromConfig(config) + ]) + ]) + + const cwd = (config.terminal?.cwd ?? '').trim() + + if (cwd && cwd !== '.') { + setCurrentCwd(prev => prev || cwd) + void refreshProjectBranch($currentCwd.get() || cwd) + } + + const reasoning = (config.agent?.reasoning_effort ?? '').trim() + const tier = (config.agent?.service_tier ?? '').trim() + + setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning)) + setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier)) + setCurrentFastMode(prev => (activeSessionIdRef.current ? prev : FAST_TIERS.has(tier.toLowerCase()))) + + setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) + setSttEnabled(config.stt?.enabled !== false) + } catch { + // Config is nice-to-have; chat still works without it. + } + }, [activeSessionIdRef, refreshProjectBranch]) + + return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts new file mode 100644 index 000000000..20f5221f3 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -0,0 +1,859 @@ +import type { QueryClient } from '@tanstack/react-query' +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' + +import { + appendAssistantTextPart, + appendReasoningPart, + assistantTextPart, + type ChatMessage, + type ChatMessagePart, + chatMessageText, + type GatewayEventPayload, + reasoningPart, + renderMediaTags, + upsertToolPart +} from '@/lib/chat-messages' +import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { setClarifyRequest } from '@/store/clarify' +import { notify } from '@/store/notifications' +import { requestDesktopOnboarding } from '@/store/onboarding' +import { + setCurrentBranch, + setCurrentCwd, + setCurrentFastMode, + setCurrentModel, + setCurrentPersonality, + setCurrentProvider, + setCurrentReasoningEffort, + setCurrentServiceTier, + setCurrentUsage, + setTurnStartedAt +} from '@/store/session' +import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' +import { recordToolDiff } from '@/store/tool-diffs' +import type { RpcEvent } from '@/types/hermes' + +import type { ClientSessionState } from '../../types' + +interface MessageStreamOptions { + activeSessionIdRef: MutableRefObject<string | null> + hydrateFromStoredSession: ( + attempts?: number, + storedSessionId?: string | null, + runtimeSessionId?: string | null + ) => Promise<void> + queryClient: QueryClient + refreshHermesConfig: () => Promise<void> + refreshSessions: () => Promise<void> + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => ClientSessionState +} + +interface QueuedStreamDeltas { + assistant: string + reasoning: string +} + +// Minimum gap between two assistant-text flushes during a stream. Was 16ms +// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every +// token got its own React commit + Streamdown markdown re-parse, scaling +// linearly with the growing last-block length. Bumping to 33ms lets ~2 tokens +// batch into one commit at 60 tok/sec without introducing visible lag on the +// streaming text (still 30 fps of visible text growth). Big perceived +// smoothness win on long messages with big trailing paragraphs; see +// `scripts/profile-typing-lag.md` for the measurement work behind this. +const STREAM_DELTA_FLUSH_MS = 33 + +// Gateway/provider failures sometimes arrive as message.complete text instead +// of an explicit error event. Treat matches as inline assistant errors so they +// persist like real error events and don't get erased by hydrate fallback. +const COMPLETION_ERROR_PATTERNS = [ + /^API call failed after \d+ retries:/i, + /^HTTP\s+\d{3}\b/i, + /^(Provider|Gateway)\s+error:/i +] + +function completionErrorText(finalText: string): string | null { + const text = finalText.trim() + + return text && COMPLETION_ERROR_PATTERNS.some(re => re.test(text)) ? text : null +} + +const SUBAGENT_EVENT_TYPES = new Set([ + 'subagent.spawn_requested', + 'subagent.start', + 'subagent.thinking', + 'subagent.tool', + 'subagent.progress', + 'subagent.complete' +]) + +// Anonymous progress events that carry todos but no name still belong to the +// todo stream; named todo events are obviously routed there too. +function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined { + if (!payload) { + return undefined + } + + const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos')) + + return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined +} + +function asRecord(value: unknown): Record<string, unknown> { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {} +} + +function parseMaybeRecord(value: unknown): Record<string, unknown> { + if (typeof value === 'string') { + try { + return asRecord(JSON.parse(value)) + } catch { + return {} + } + } + + return asRecord(value) +} + +const firstString = (...candidates: unknown[]): string => { + for (const v of candidates) { + if (typeof v === 'string' && v) { + return v + } + } + + return '' +} + +function delegateTaskPayloads( + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string +): Record<string, unknown>[] { + if (payload?.name !== 'delegate_task') { + return [] + } + + const args = parseMaybeRecord(payload.args ?? payload.input) + const result = parseMaybeRecord(payload.result) + const rawTasks = Array.isArray(args.tasks) ? args.tasks : [] + const tasks = rawTasks.length ? rawTasks.map(parseMaybeRecord) : [args] + const status = phase === 'complete' ? (payload.error ? 'failed' : 'completed') : 'running' + const toolId = payload.tool_id || payload.tool_call_id || payload.id || 'delegate_task' + const progressText = firstString(payload.preview, payload.message, payload.context) + + const eventType = + phase === 'complete' + ? 'subagent.complete' + : sourceEventType === 'tool.start' + ? 'subagent.start' + : 'subagent.progress' + + return tasks.map((task, index) => { + const goal = firstString(task.goal, args.goal, payload.context) || 'Delegated task' + const summary = firstString(result.summary, payload.summary, payload.message) + + return { + depth: 0, + duration_seconds: payload.duration_s, + goal, + status, + subagent_id: `delegate-tool:${toolId}:${index}`, + summary: summary || undefined, + task_count: tasks.length, + task_index: index, + text: eventType === 'subagent.progress' ? progressText || goal : undefined, + tool_name: eventType === 'subagent.start' ? 'delegate_task' : undefined, + tool_preview: eventType === 'subagent.start' ? progressText : undefined, + toolsets: Array.isArray(task.toolsets) ? task.toolsets : Array.isArray(args.toolsets) ? args.toolsets : [], + event_type: eventType, + output_tail: + phase === 'complete' && summary + ? [{ is_error: Boolean(payload.error), preview: summary, tool: 'delegate_task' }] + : undefined + } + }) +} + +export function useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession, + queryClient, + refreshHermesConfig, + refreshSessions, + updateSessionState +}: MessageStreamOptions) { + // Patch the in-flight assistant message (or seed it). Centralises the + // streamId/groupId bookkeeping every event callback would otherwise repeat. + const mutateStream = useCallback( + ( + sessionId: string, + transform: (parts: ChatMessagePart[], message: ChatMessage) => ChatMessagePart[], + seed: () => ChatMessagePart[], + opts: { + pending?: (message: ChatMessage) => boolean + } = {} + ) => { + const apply = () => { + updateSessionState(sessionId, state => { + // After a stop, drop any late deltas / tool events for the + // cancelled turn so they don't keep growing the (now finalized) + // assistant bubble or, worse, seed a brand-new bubble that + // appears to belong to the next user message. + if (state.interrupted) { + return state + } + + const streamId = state.streamId ?? `assistant-stream-${Date.now()}` + const groupId = state.pendingBranchGroup ?? undefined + const prev = state.messages + let nextMessages: ChatMessage[] + + if (!prev.some(m => m.id === streamId)) { + nextMessages = [ + ...prev, + { + id: streamId, + role: 'assistant', + parts: seed(), + pending: true, + branchGroupId: groupId + } + ] + } else { + nextMessages = prev.map(m => + m.id === streamId + ? { + ...m, + parts: transform(m.parts, m), + pending: opts.pending ? opts.pending(m) : true + } + : m + ) + } + + return { + ...state, + messages: nextMessages, + streamId, + sawAssistantPayload: true, + awaitingResponse: false + } + }) + } + + apply() + }, + [updateSessionState] + ) + + const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map()) + const flushHandleRef = useRef<number | null>(null) + const lastFlushAtRef = useRef<number>(0) + const nativeSubagentSessionsRef = useRef<Set<string>>(new Set()) + + const flushQueuedDeltas = useCallback( + (sessionId?: string) => { + const queue = queuedDeltasRef.current + const ids = sessionId ? [sessionId] : [...queue.keys()] + + for (const id of ids) { + const queued = queue.get(id) + + if (!queued) { + continue + } + + queue.delete(id) + + if (queued.assistant) { + mutateStream( + id, + parts => appendAssistantTextPart(parts, queued.assistant), + () => [assistantTextPart(queued.assistant)] + ) + } + + if (queued.reasoning) { + mutateStream( + id, + parts => appendReasoningPart(parts, queued.reasoning), + () => [reasoningPart(queued.reasoning)] + ) + } + } + }, + [mutateStream] + ) + + const scheduleDeltaFlush = useCallback(() => { + if (flushHandleRef.current !== null) { + return + } + + if (typeof window === 'undefined') { + flushQueuedDeltas() + + return + } + + // Enforce a floor on the gap between two flushes. Without it, an LLM + // emitting tokens slower than the rAF cadence (~30-80 tok/sec is typical) + // forces one React commit + Streamdown re-parse per token, and the + // last-block markdown re-parse cost is roughly linear in current block + // length. With this floor, slower streams still coalesce ~2 tokens per + // commit and the synthetic harness shows longtask counts drop from ~5/5s + // to ~1/5s on big sessions (see scripts/profile-typing-lag.md). + const sinceLast = performance.now() - lastFlushAtRef.current + const runFlush = () => { + flushHandleRef.current = null + lastFlushAtRef.current = performance.now() + flushQueuedDeltas() + } + + if (sinceLast >= STREAM_DELTA_FLUSH_MS && typeof window.requestAnimationFrame === 'function') { + flushHandleRef.current = window.requestAnimationFrame(runFlush) + + return + } + + flushHandleRef.current = window.setTimeout( + runFlush, + Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast) + ) + }, [flushQueuedDeltas]) + + const queueDelta = useCallback( + (sessionId: string, key: keyof QueuedStreamDeltas, delta: string) => { + if (!delta) { + return + } + + const queued = queuedDeltasRef.current.get(sessionId) ?? { assistant: '', reasoning: '' } + queued[key] += delta + queuedDeltasRef.current.set(sessionId, queued) + scheduleDeltaFlush() + }, + [scheduleDeltaFlush] + ) + + useEffect( + () => () => { + if (flushHandleRef.current !== null && typeof window !== 'undefined') { + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(flushHandleRef.current) + } else { + window.clearTimeout(flushHandleRef.current) + } + } + + flushHandleRef.current = null + flushQueuedDeltas() + }, + [flushQueuedDeltas] + ) + + const appendAssistantDelta = useCallback( + (sessionId: string, delta: string) => { + if (!delta) { + return + } + + queueDelta(sessionId, 'assistant', delta) + }, + [queueDelta] + ) + + const appendReasoningDelta = useCallback( + (sessionId: string, delta: string, replace = false) => { + if (!delta) { + return + } + + if (!replace) { + queueDelta(sessionId, 'reasoning', delta) + + return + } + + flushQueuedDeltas(sessionId) + + mutateStream( + sessionId, + (parts, message) => { + if (replace && chatMessageText(message).trim()) { + return parts + } + + if (replace) { + return [...parts.filter(part => part.type !== 'reasoning'), reasoningPart(delta)] + } + + return appendReasoningPart(parts, delta) + }, + () => [reasoningPart(delta)] + ) + }, + [flushQueuedDeltas, mutateStream, queueDelta] + ) + + const upsertToolCall = useCallback( + ( + sessionId: string, + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string + ) => { + if (!nativeSubagentSessionsRef.current.has(sessionId)) { + for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) { + upsertSubagent( + sessionId, + subagentPayload, + true, + phase === 'complete' ? 'delegate.complete' : 'delegate.running' + ) + } + } + + mutateStream( + sessionId, + parts => upsertToolPart(parts, payload, phase), + () => upsertToolPart([], payload, phase), + { pending: m => phase !== 'complete' || (m.pending ?? false) } + ) + }, + [mutateStream] + ) + + const completeAssistantMessage = useCallback( + (sessionId: string, text: string) => { + let shouldHydrate = false + + const completedState = updateSessionState(sessionId, state => { + // Late completion from an already-cancelled turn: cancelRun has + // already finalized the bubble and added the [interrupted] marker; + // re-running the dedupe below would erase that marker and replace + // the partial with the (just-cancelled) full text. + if (state.interrupted) { + return state + } + + const streamId = state.streamId + const finalText = renderMediaTags(text).trim() + const completionError = completionErrorText(finalText) + const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() + const dedupeReference = normalize(finalText) + + const replaceTextPart = (parts: ChatMessagePart[]) => { + const kept = parts.filter(part => { + if (part.type === 'text') { + return false + } + + if (part.type !== 'reasoning' || !dedupeReference) { + return true + } + + const r = normalize(part.text) + + return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference))) + }) + + return finalText ? [...kept, assistantTextPart(finalText)] : kept + } + + const completeMessage = (message: ChatMessage): ChatMessage => + completionError + ? { + ...message, + error: completionError, + parts: message.parts.filter(part => part.type !== 'text'), + pending: false + } + : { + ...message, + parts: replaceTextPart(message.parts), + pending: false + } + + const newAssistantFromCompletion = (): ChatMessage => ({ + id: `assistant-${Date.now()}`, + role: 'assistant', + parts: completionError ? [] : [assistantTextPart(finalText)], + branchGroupId: state.pendingBranchGroup ?? undefined, + ...(completionError && { error: completionError }) + }) + + const prev = state.messages + let nextMessages = prev + + if (streamId && prev.some(m => m.id === streamId)) { + nextMessages = prev.map(m => (m.id === streamId ? completeMessage(m) : m)) + } else { + const fallbackIndex = [...prev] + .reverse() + .findIndex(message => message.role === 'assistant' && !message.hidden) + + if (fallbackIndex >= 0) { + const index = prev.length - 1 - fallbackIndex + const existing = prev[index] + const existingText = chatMessageText(existing).trim() + + if (existing.pending || (finalText && existingText === finalText)) { + nextMessages = prev.map((message, messageIndex) => + messageIndex === index ? completeMessage(message) : message + ) + } else if (finalText) { + nextMessages = [...prev, newAssistantFromCompletion()] + } + } else if (finalText) { + nextMessages = [...prev, newAssistantFromCompletion()] + } + } + + const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) + const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) + const unresolvedUserTail = lastVisible?.role === 'user' + shouldHydrate = + !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) + + return { + ...state, + messages: nextMessages, + streamId: null, + pendingBranchGroup: null, + awaitingResponse: false, + busy: false + } + }) + + void refreshSessions().catch(() => undefined) + + if (shouldHydrate) { + void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId) + } + + if (document.hidden && sessionId === activeSessionIdRef.current) { + void window.hermesDesktop?.notify({ + title: 'Hermes finished', + body: text.slice(0, 140) || 'The response is ready.' + }) + } + }, + [activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState] + ) + + const failAssistantMessage = useCallback( + (sessionId: string, errorMessage: string) => { + updateSessionState(sessionId, state => { + const streamId = state.streamId ?? `assistant-error-${Date.now()}` + const groupId = state.pendingBranchGroup ?? undefined + const prev = state.messages + const error = errorMessage.trim() || 'Hermes reported an error' + + const nextMessages = prev.some(m => m.id === streamId) + ? prev.map(message => + message.id === streamId + ? { + ...message, + error, + pending: false + } + : message + ) + : [ + ...prev, + { + id: streamId, + role: 'assistant' as const, + parts: [], + error, + pending: false, + branchGroupId: groupId + } + ] + + return { + ...state, + messages: nextMessages, + streamId: null, + pendingBranchGroup: null, + sawAssistantPayload: true, + awaitingResponse: false, + busy: false + } + }) + }, + [updateSessionState] + ) + + const handleGatewayEvent = useCallback( + (event: RpcEvent) => { + const payload = event.payload as GatewayEventPayload | undefined + const explicitSid = event.session_id || '' + const sessionId = explicitSid || activeSessionIdRef.current + const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current + + if (event.type === 'gateway.ready') { + return + } else if (event.type === 'session.info') { + // Apply session-scoped fields when the event targets the active + // session, OR when it's a global broadcast and we have no session. + const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current + const modelChanged = typeof payload?.model === 'string' + const providerChanged = typeof payload?.provider === 'string' + const runningChanged = typeof payload?.running === 'boolean' + + if (apply) { + const runtimeInfo: { branch?: string; cwd?: string } = {} + + if (modelChanged) { + setCurrentModel(payload!.model || '') + } + + if (providerChanged) { + setCurrentProvider(payload!.provider || '') + } + + if (typeof payload?.cwd === 'string') { + setCurrentCwd(payload.cwd) + runtimeInfo.cwd = payload.cwd + } + + if (typeof payload?.branch === 'string') { + setCurrentBranch(payload.branch) + runtimeInfo.branch = payload.branch + } + + if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) { + updateSessionState(sessionId, state => ({ + ...state, + branch: runtimeInfo.branch ?? state.branch, + cwd: runtimeInfo.cwd ?? state.cwd + })) + } + + if (typeof payload?.personality === 'string') { + setCurrentPersonality(normalizePersonalityValue(payload.personality)) + } + + if (typeof payload?.reasoning_effort === 'string') { + setCurrentReasoningEffort(payload.reasoning_effort) + } + + if (typeof payload?.service_tier === 'string') { + setCurrentServiceTier(payload.service_tier) + } + + if (typeof payload?.fast === 'boolean') { + setCurrentFastMode(payload.fast) + } + + if (runningChanged && sessionId) { + updateSessionState(sessionId, state => { + const busy = Boolean(payload!.running) + + if (state.busy === busy && (busy || !state.awaitingResponse)) { + return state + } + + if (busy) { + return { + ...state, + busy + } + } + + if (state.awaitingResponse && !state.sawAssistantPayload) { + return state + } + + return { + ...state, + awaitingResponse: false, + busy, + pendingBranchGroup: null, + streamId: null + } + }) + } + } + + if (payload?.usage && (!explicitSid || isActiveEvent)) { + setCurrentUsage(current => ({ ...current, ...payload.usage })) + } + + if (typeof payload?.credential_warning === 'string' && payload.credential_warning) { + requestDesktopOnboarding(payload.credential_warning) + } + + void refreshHermesConfig() + + if (modelChanged || providerChanged) { + void queryClient.invalidateQueries({ + queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options'] + }) + } + } else if (event.type === 'message.start') { + if (!sessionId) { + return + } + + flushQueuedDeltas(sessionId) + clearSessionSubagents(sessionId) + nativeSubagentSessionsRef.current.delete(sessionId) + + if (isActiveEvent) { + triggerHaptic('streamStart') + } + + updateSessionState(sessionId, state => ({ + ...state, + busy: true, + awaitingResponse: true, + sawAssistantPayload: false, + interrupted: false + })) + + if (isActiveEvent) { + setTurnStartedAt(Date.now()) + } + } else if (event.type === 'message.delta') { + if (sessionId) { + appendAssistantDelta(sessionId, coerceGatewayText(payload?.text)) + } + } else if (event.type === 'thinking.delta') { + // thinking.delta carries the kawaii spinner status (face + verb from + // KawaiiSpinner), not real reasoning. The bottom-of-thread loading + // indicator already covers that UX, so we ignore these events to + // avoid a duplicative "Thinking" disclosure showing spinner text. + } else if (event.type === 'reasoning.delta') { + if (sessionId) { + appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) + } + } else if (event.type === 'reasoning.available') { + if (sessionId) { + appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true) + } + } else if (event.type === 'message.complete') { + if (!sessionId) { + return + } + + flushQueuedDeltas(sessionId) + + if (isActiveEvent) { + triggerHaptic('streamDone') + } + + const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered) + completeAssistantMessage(sessionId, finalText) + + if (isActiveEvent) { + setTurnStartedAt(null) + } + + if (payload?.usage) { + setCurrentUsage(current => ({ ...current, ...payload.usage })) + } + } else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') { + if (!sessionId) { + return + } + + flushQueuedDeltas(sessionId) + upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) + } else if (event.type === 'tool.complete') { + if (sessionId) { + flushQueuedDeltas(sessionId) + upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) + } + + if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) { + recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff) + } + } else if (SUBAGENT_EVENT_TYPES.has(event.type)) { + if (sessionId && payload) { + if (!nativeSubagentSessionsRef.current.has(sessionId)) { + pruneDelegateFallbackSubagents(sessionId) + } + + nativeSubagentSessionsRef.current.add(sessionId) + upsertSubagent( + sessionId, + payload as Record<string, unknown>, + event.type === 'subagent.spawn_requested' || event.type === 'subagent.start', + event.type + ) + } + } else if (event.type === 'clarify.request') { + if (!isActiveEvent) { + return + } + + // Surface the clarify tool's overlay. The Python side is blocked on + // `clarify.respond`, so without this handler the agent would hang + // forever (see tools/clarify_tool.py + tui_gateway/server.py:_block). + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + const question = typeof payload?.question === 'string' ? payload.question : '' + + if (requestId && question) { + setClarifyRequest({ + requestId, + question, + choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null, + sessionId: sessionId ?? null + }) + } + } else if (event.type === 'error') { + const errorMessage = payload?.message || 'Hermes reported an error' + const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage) + + if (looksLikeProviderSetup) { + requestDesktopOnboarding(errorMessage) + } else if (isActiveEvent) { + notify({ + kind: 'error', + title: 'Hermes error', + message: errorMessage + }) + } + + if (sessionId) { + flushQueuedDeltas(sessionId) + failAssistantMessage(sessionId, errorMessage) + } + + if (isActiveEvent) { + setTurnStartedAt(null) + } + } + }, + [ + appendAssistantDelta, + appendReasoningDelta, + activeSessionIdRef, + completeAssistantMessage, + failAssistantMessage, + flushQueuedDeltas, + queryClient, + refreshHermesConfig, + updateSessionState, + upsertToolCall + ] + ) + + return { + appendAssistantDelta, + appendReasoningDelta, + completeAssistantMessage, + handleGatewayEvent, + upsertToolCall + } +} diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts new file mode 100644 index 000000000..44dc54cef --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -0,0 +1,88 @@ +import { type QueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' + +import { getGlobalModelInfo, setGlobalModel } from '@/hermes' +import { notifyError } from '@/store/notifications' +import { setCurrentModel, setCurrentProvider } from '@/store/session' +import type { ModelOptionsResponse } from '@/types/hermes' + +interface ModelSelection { + model: string + persistGlobal: boolean + provider: string +} + +interface ModelControlsOptions { + activeSessionId: string | null + queryClient: QueryClient + requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> +} + +export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) { + const updateModelOptionsCache = useCallback( + (provider: string, model: string, includeGlobal: boolean) => { + const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model }) + + queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch) + + if (includeGlobal) { + queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch) + } + }, + [activeSessionId, queryClient] + ) + + const refreshCurrentModel = useCallback(async () => { + try { + const result = await getGlobalModelInfo() + + if (typeof result.model === 'string') { + setCurrentModel(result.model) + } + + if (typeof result.provider === 'string') { + setCurrentProvider(result.provider) + } + } catch { + // The delayed session.info event still updates this once the agent is ready. + } + }, []) + + const selectModel = useCallback( + (selection: ModelSelection) => { + setCurrentModel(selection.model) + setCurrentProvider(selection.provider) + updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId) + + void (async () => { + try { + if (activeSessionId) { + await requestGateway('slash.exec', { + session_id: activeSessionId, + command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` + }) + + if (selection.persistGlobal) { + void refreshCurrentModel() + } + + void queryClient.invalidateQueries({ + queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId] + }) + + return + } + + await setGlobalModel(selection.provider, selection.model) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + } catch (err) { + notifyError(err, 'Model switch failed') + } + })() + }, + [activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache] + ) + + return { refreshCurrentModel, selectModel, updateModelOptionsCache } +} diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx new file mode 100644 index 000000000..1134ffe4f --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx @@ -0,0 +1,168 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages' +import { + $previewTarget, + clearSessionPreviewRegistry, + type PreviewTarget, + registerSessionPreview +} from '@/store/preview' +import { $currentCwd, $messages } from '@/store/session' +import type { RpcEvent } from '@/types/hermes' + +import { usePreviewRouting } from './use-preview-routing' + +function assistantMessage(id: string, text: string): ChatMessage { + return { + id, + parts: [assistantTextPart(text)], + role: 'assistant' + } +} + +function previewTarget(source: string): PreviewTarget { + const isUrl = /^https?:\/\//i.test(source) + + return { + kind: isUrl ? 'url' : 'file', + label: source, + path: isUrl ? undefined : source, + previewKind: isUrl ? undefined : 'html', + source, + url: isUrl ? source : `file://${source}` + } +} + +let handleEvent: (event: RpcEvent) => void = () => undefined + +function PreviewRoutingHarness({ onEvent }: { onEvent: (handler: (event: RpcEvent) => void) => void }) { + const activeSessionIdRef = useRef<string | null>('session-1') + + const routing = usePreviewRouting({ + activeSessionIdRef, + baseHandleGatewayEvent: vi.fn(), + currentCwd: '/work', + currentView: 'chat', + requestGateway: vi.fn(), + routedSessionId: 'session-1', + selectedStoredSessionId: null + }) + + useEffect(() => { + onEvent(routing.handleDesktopGatewayEvent) + }, [onEvent, routing.handleDesktopGatewayEvent]) + + return null +} + +describe('usePreviewRouting', () => { + beforeEach(() => { + $currentCwd.set('/work') + $messages.set([]) + $previewTarget.set(null) + window.localStorage.clear() + clearSessionPreviewRegistry() + handleEvent = () => undefined + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + normalizePreviewTarget: vi.fn(async (target: string) => previewTarget(target)) + } + }) + }) + + afterEach(() => { + cleanup() + $messages.set([]) + $previewTarget.set(null) + window.localStorage.clear() + clearSessionPreviewRegistry() + vi.restoreAllMocks() + }) + + it('opens the active session preview from the registry', async () => { + const target = previewTarget('/work/demo.html') + + registerSessionPreview('session-1', target, 'tool-result') + render( + <PreviewRoutingHarness + onEvent={handler => { + handleEvent = handler + }} + /> + ) + + await waitFor(() => { + expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' }) + }) + }) + + it('does not infer previews from assistant prose', async () => { + render( + <PreviewRoutingHarness + onEvent={handler => { + handleEvent = handler + }} + /> + ) + + act(() => { + $messages.set([ + assistantMessage('a1', 'Preview: http://localhost:5173/'), + assistantMessage('a2', 'Open /work/demo.html') + ]) + }) + + expect($previewTarget.get()).toBeNull() + expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled() + }) + + it('registers structured tool-result preview targets', async () => { + render( + <PreviewRoutingHarness + onEvent={handler => { + handleEvent = handler + }} + /> + ) + + act(() => + handleEvent({ + payload: { path: './dist/index.html' }, + session_id: 'session-1', + type: 'tool.complete' + }) + ) + + await waitFor(() => { + expect($previewTarget.get()?.source).toBe('./dist/index.html') + }) + + expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html') + }) + + it('registers html previews from edit inline diffs', async () => { + render( + <PreviewRoutingHarness + onEvent={handler => { + handleEvent = handler + }} + /> + ) + + act(() => + handleEvent({ + payload: { inline_diff: '\u001b[38;2;218;165;32ma/preview-demo.html -> b/preview-demo.html\u001b[0m\n' }, + session_id: 'session-1', + type: 'tool.complete' + }) + ) + + await waitFor(() => { + expect($previewTarget.get()?.source).toBe('preview-demo.html') + }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.ts b/apps/desktop/src/app/session/hooks/use-preview-routing.ts new file mode 100644 index 000000000..0d48927af --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.ts @@ -0,0 +1,223 @@ +import { useStore } from '@nanostores/react' +import { type MutableRefObject, useCallback, useEffect } from 'react' + +import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events' +import { + $previewTarget, + $sessionPreviewRegistry, + beginPreviewServerRestart, + completePreviewServerRestart, + getSessionPreviewRecord, + progressPreviewServerRestart, + requestPreviewReload, + setPreviewTarget, + setSessionPreviewTarget +} from '@/store/preview' +import { $currentCwd } from '@/store/session' +import type { RpcEvent } from '@/types/hermes' + +type EventHandler = (event: RpcEvent) => void + +interface PreviewRoutingOptions { + activeSessionIdRef: MutableRefObject<string | null> + baseHandleGatewayEvent: EventHandler + currentCwd: string + currentView: string + requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> + routedSessionId: string | null + selectedStoredSessionId: string | null +} + +function asRecord(payload: unknown): Record<string, unknown> { + return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {} +} + +function activePreviewSessionId( + activeSessionIdRef: MutableRefObject<string | null>, + routedSessionId: string | null, + selectedStoredSessionId: string | null +): string { + return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || '' +} + +function looksLikePreviewTarget(value: string): boolean { + return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) +} + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') +} + +function htmlPathFromInlineDiff(value: string): string { + const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '') + + for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) { + const candidate = match[1]?.trim() + + if (candidate) { + return candidate + } + } + + return '' +} + +function structuredPreviewCandidate(payload: unknown): string { + const record = asRecord(payload) + const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview'] + + for (const field of fields) { + const value = record[field] + + if (typeof value === 'string') { + const target = value.trim() + + if (target && looksLikePreviewTarget(target)) { + return target + } + } + } + + const inlineDiff = record.inline_diff + + if (typeof inlineDiff === 'string') { + return htmlPathFromInlineDiff(inlineDiff) + } + + return '' +} + +export function usePreviewRouting({ + activeSessionIdRef, + baseHandleGatewayEvent, + currentCwd, + currentView, + requestGateway, + routedSessionId, + selectedStoredSessionId +}: PreviewRoutingOptions) { + const previewRegistry = useStore($sessionPreviewRegistry) + const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) + + useEffect(() => { + if (currentView !== 'chat' || !previewSessionId) { + setPreviewTarget(null) + + return + } + + const record = getSessionPreviewRecord(previewSessionId) + + setPreviewTarget(record?.normalized ?? null) + }, [currentView, previewRegistry, previewSessionId]) + + const registerStructuredPreview = useCallback( + async (event: RpcEvent) => { + if ( + event.session_id && + event.session_id !== activeSessionIdRef.current && + event.session_id !== previewSessionId + ) { + return + } + + if (!event.type.startsWith('tool.')) { + return + } + + if (!previewSessionId) { + return + } + + const candidate = structuredPreviewCandidate(event.payload) + + if (!candidate) { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.normalizePreviewTarget) { + return + } + + const sessionId = previewSessionId + const cwd = currentCwd || '' + const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null) + + if ( + !target || + sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) || + $currentCwd.get() !== cwd + ) { + return + } + + setSessionPreviewTarget(sessionId, target, 'tool-result', candidate) + }, + [activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId] + ) + + const restartPreviewServer = useCallback( + async (url: string, context?: string) => { + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + throw new Error('No active session for background restart') + } + + const cwd = $currentCwd.get() || currentCwd || '' + + const result = await requestGateway<{ task_id?: string }>('preview.restart', { + context: context || undefined, + cwd: cwd || undefined, + session_id: sessionId, + url + }) + + const taskId = result.task_id || '' + + if (!taskId) { + throw new Error('Background restart did not return a task id') + } + + beginPreviewServerRestart(taskId, url) + + return taskId + }, + [activeSessionIdRef, currentCwd, requestGateway] + ) + + const handleDesktopGatewayEvent = useCallback<EventHandler>( + event => { + baseHandleGatewayEvent(event) + + if (event.type === 'preview.restart.complete') { + const { task_id, text } = asRecord(event.payload) + + if (typeof task_id === 'string' && task_id) { + completePreviewServerRestart(task_id, typeof text === 'string' ? text : '') + } + } else if (event.type === 'preview.restart.progress') { + const { task_id, text } = asRecord(event.payload) + + if (typeof task_id === 'string' && task_id) { + progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '') + } + } + + if (event.session_id && event.session_id !== activeSessionIdRef.current) { + return + } + + void registerStructuredPreview(event) + + if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) { + requestPreviewReload() + } + }, + [activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview] + ) + + return { handleDesktopGatewayEvent, restartPreviewServer } +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts new file mode 100644 index 000000000..62d685fc7 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -0,0 +1,806 @@ +import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' +import { type MutableRefObject, useCallback } from 'react' + +import { transcribeAudio } from '@/hermes' +import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' +import { + attachmentDisplayText, + INTERRUPTED_MARKER, + parseCommandDispatch, + parseSlashCommand, + pathLabel, + SLASH_COMMAND_RE +} from '@/lib/chat-runtime' +import { + type CommandsCatalogLike, + desktopSlashUnavailableMessage, + filterDesktopCommandsCatalog, + isDesktopSlashCommand +} from '@/lib/desktop-slash-commands' +import { triggerHaptic } from '@/lib/haptics' +import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { + $composerAttachments, + addComposerAttachment, + clearComposerAttachments, + type ComposerAttachment, + terminalContextBlocksFromDraft +} from '@/store/composer' +import { clearNotifications, notify, notifyError } from '@/store/notifications' +import { requestDesktopOnboarding } from '@/store/onboarding' +import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session' + +import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types' + +function blobToDataUrl(blob: Blob): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.addEventListener('load', () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Could not read recorded audio')) + } + }) + reader.addEventListener('error', () => reject(reader.error || new Error('Could not read recorded audio'))) + reader.readAsDataURL(blob) + }) +} + +function isProviderSetupError(error: unknown) { + const message = error instanceof Error ? error.message : String(error) + + return isProviderSetupErrorMessage(message) +} + +function inlineErrorMessage(error: unknown, fallback: string): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback + + return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() +} + +interface PromptActionsOptions { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject<string | null> + busyRef: MutableRefObject<boolean> + branchCurrentSession: () => Promise<boolean> + createBackendSessionForSend: () => Promise<string | null> + handleSkinCommand: (arg: string) => string + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> + selectedStoredSessionIdRef: MutableRefObject<string | null> + startFreshSessionDraft: () => void + sttEnabled: boolean + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => ClientSessionState +} + +interface SubmitTextOptions { + attachments?: ComposerAttachment[] + fromQueue?: boolean +} + +function renderCommandsCatalog(catalog: CommandsCatalogLike): string { + const desktopCatalog = filterDesktopCommandsCatalog(catalog) + + const sections = desktopCatalog.categories?.length + ? desktopCatalog.categories + : [{ name: 'Desktop commands', pairs: desktopCatalog.pairs ?? [] }] + + const body = sections + .filter(section => section.pairs.length > 0) + .map(section => { + const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`) + + return [`${section.name}:`, ...rows].join('\n') + }) + .join('\n\n') + + const tail = [ + desktopCatalog.skill_count ? `${desktopCatalog.skill_count} skill commands available.` : '', + desktopCatalog.warning ? `warning: ${desktopCatalog.warning}` : '' + ] + .filter(Boolean) + .join('\n') + + return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n') +} + +function slashStatusText(command: string, output: string): string { + return [`slash:${command}`, output.trim()].filter(Boolean).join('\n') +} + +function appendText(message: AppendMessage): string { + return message.content + .map(part => ('text' in part ? part.text : '')) + .join('') + .trim() +} + +function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number { + return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length +} + +export function usePromptActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + branchCurrentSession, + createBackendSessionForSend, + handleSkinCommand, + requestGateway, + selectedStoredSessionIdRef, + startFreshSessionDraft, + sttEnabled, + updateSessionState +}: PromptActionsOptions) { + const appendSessionTextMessage = useCallback( + (sessionId: string, role: ChatMessage['role'], text: string) => { + const body = text.trim() + + if (!body) { + return + } + + updateSessionState( + sessionId, + state => ({ + ...state, + messages: [ + ...state.messages, + { + id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + role, + parts: [textPart(body)] + } + ] + }), + selectedStoredSessionIdRef.current + ) + }, + [selectedStoredSessionIdRef, updateSessionState] + ) + + const syncImageAttachmentsForSubmit = useCallback( + async ( + sessionId: string, + attachments: ComposerAttachment[], + options: { updateComposerAttachments?: boolean } = {} + ) => { + const updateComposerAttachments = options.updateComposerAttachments ?? true + const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path) + + for (const attachment of images) { + if (attachment.attachedSessionId === sessionId) { + continue + } + + const result = await requestGateway<ImageAttachResponse>('image.attach', { + session_id: sessionId, + path: attachment.path + }) + + if (!result.attached) { + const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image') + throw new Error(result.message || `Could not attach ${label}`) + } + + const attachedPath = result.path || attachment.path + + if (updateComposerAttachments) { + addComposerAttachment({ + ...attachment, + id: attachment.id, + label: attachedPath ? pathLabel(attachedPath) : attachment.label, + path: attachedPath, + attachedSessionId: sessionId + }) + } + } + }, + [requestGateway] + ) + + const submitPromptText = useCallback( + async (rawText: string, options?: SubmitTextOptions) => { + const visibleText = rawText.trim() + const usingComposerAttachments = !options?.attachments + const attachments = options?.attachments ?? $composerAttachments.get() + + const contextRefs = attachments + .map(a => a.refText) + .filter(Boolean) + .join('\n') + + const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') + const hasImage = attachments.some(a => a.kind === 'image') + const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r)) + + const text = + [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') || + (hasImage ? 'What do you see in this image?' : '') + + if (!text || busyRef.current) { + return false + } + + const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + const userMessage: ChatMessage = { + id: optimisticId, + role: 'user', + parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], + attachmentRefs + } + + const releaseBusy = () => { + busyRef.current = false + setBusy(false) + setAwaitingResponse(false) + } + + // Idempotent optimistic insert — re-running with the resolved sessionId + // after createBackendSessionForSend just overwrites with the same id. + const seedOptimistic = (sid: string) => + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.some(m => m.id === optimisticId) + ? state.messages + : [...state.messages, userMessage], + busy: true, + awaitingResponse: true, + pendingBranchGroup: null, + sawAssistantPayload: false, + interrupted: state.interrupted + }), + selectedStoredSessionIdRef.current + ) + + const dropOptimistic = (sid: null | string) => { + if (!sid) { + setMessages(current => current.filter(m => m.id !== optimisticId)) + + return + } + + updateSessionState( + sid, + state => ({ + ...state, + messages: state.messages.filter(m => m.id !== optimisticId), + busy: false, + awaitingResponse: false, + pendingBranchGroup: null + }), + selectedStoredSessionIdRef.current + ) + } + + busyRef.current = true + setBusy(true) + setAwaitingResponse(true) + clearNotifications() + + let sessionId: null | string = activeSessionId + + if (sessionId) { + seedOptimistic(sessionId) + } else { + setMessages(current => [...current, userMessage]) + } + + if (!sessionId) { + try { + sessionId = await createBackendSessionForSend() + } catch (err) { + dropOptimistic(null) + releaseBusy() + notifyError(err, 'Session unavailable') + + return false + } + + if (!sessionId) { + dropOptimistic(null) + releaseBusy() + notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) + + return false + } + + seedOptimistic(sessionId) + } + + try { + await syncImageAttachmentsForSubmit(sessionId, attachments, { + updateComposerAttachments: usingComposerAttachments + }) + await requestGateway('prompt.submit', { session_id: sessionId, text }) + + if (usingComposerAttachments) { + clearComposerAttachments() + } + + return true + } catch (err) { + const message = inlineErrorMessage(err, 'Prompt failed') + + releaseBusy() + updateSessionState(sessionId, state => ({ + ...state, + messages: [ + ...state.messages, + { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + parts: [], + error: message || 'Prompt failed', + branchGroupId: state.pendingBranchGroup ?? undefined + } + ], + busy: false, + awaitingResponse: false, + pendingBranchGroup: null, + sawAssistantPayload: true + })) + + if (isProviderSetupError(err)) { + requestDesktopOnboarding('Add a provider credential before sending your first message.') + + return false + } + + notifyError(err, 'Prompt failed') + + return false + } + }, + [ + activeSessionId, + busyRef, + createBackendSessionForSend, + requestGateway, + selectedStoredSessionIdRef, + syncImageAttachmentsForSubmit, + updateSessionState + ] + ) + + const executeSlashCommand = useCallback( + async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => { + const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => { + const command = commandText.trim() + const { name, arg } = parseSlashCommand(command) + const normalizedName = name.toLowerCase() + + if (!name) { + const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) + + if (sessionId) { + appendSessionTextMessage(sessionId, 'system', 'empty slash command') + } + + return + } + + if (normalizedName === 'new' || normalizedName === 'reset') { + startFreshSessionDraft() + + return + } + + if (normalizedName === 'branch' || normalizedName === 'fork') { + await branchCurrentSession() + + return + } + + if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) { + notify({ kind: 'success', message: handleSkinCommand(arg) }) + + return + } + + const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) + + if (!sessionId) { + notify({ + kind: 'error', + title: 'Session unavailable', + message: 'Could not create a new session' + }) + + return + } + + const renderSlashOutput = (text: string) => + appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) + + if (normalizedName === 'skin') { + renderSlashOutput(handleSkinCommand(arg)) + + return + } + + if (name === 'help' || name === 'commands') { + try { + const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId }) + + renderSlashOutput(renderCommandsCatalog(catalog)) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + + return + } + + if (!isDesktopSlashCommand(name)) { + renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) + + return + } + + try { + const result = await requestGateway<SlashExecResponse>('slash.exec', { + session_id: sessionId, + command: command.replace(/^\/+/, '') + }) + + const body = result?.output || `/${name}: no output` + renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body) + + return + } catch { + // Fall back to command.dispatch for skill/send/alias directives. + } + + try { + const dispatch = parseCommandDispatch( + await requestGateway<unknown>('command.dispatch', { + session_id: sessionId, + name, + arg + }) + ) + + if (!dispatch) { + renderSlashOutput('error: invalid response: command.dispatch') + + return + } + + if (dispatch.type === 'exec' || dispatch.type === 'plugin') { + renderSlashOutput(dispatch.output ?? '(no output)') + + return + } + + if (dispatch.type === 'alias') { + await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false) + + return + } + + const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? '' + + if (!message) { + renderSlashOutput( + `/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}` + ) + + return + } + + if (dispatch.type === 'skill') { + renderSlashOutput(`⚡ loading skill: ${dispatch.name}`) + } + + if (busyRef.current) { + renderSlashOutput('session busy — /interrupt the current turn before sending this command') + + return + } + + await submitPromptText(message) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + } + + await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true) + }, + [ + activeSessionIdRef, + appendSessionTextMessage, + branchCurrentSession, + busyRef, + createBackendSessionForSend, + handleSkinCommand, + requestGateway, + startFreshSessionDraft, + submitPromptText + ] + ) + + const submitText = useCallback( + async (rawText: string, options?: SubmitTextOptions) => { + const visibleText = rawText.trim() + const attachments = options?.attachments ?? $composerAttachments.get() + + if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { + triggerHaptic('selection') + await executeSlashCommand(visibleText) + + return true + } + + return await submitPromptText(rawText, options) + }, + [executeSlashCommand, submitPromptText] + ) + + const transcribeVoiceAudio = useCallback( + async (audio: Blob) => { + if (!sttEnabled) { + throw new Error('Speech-to-text is disabled in settings.') + } + + const dataUrl = await blobToDataUrl(audio) + const result = await transcribeAudio(dataUrl, audio.type) + + return result.transcript + }, + [sttEnabled] + ) + + const cancelRun = useCallback(async () => { + const sessionId = activeSessionId || activeSessionIdRef.current + + busyRef.current = false + setBusy(false) + setAwaitingResponse(false) + + const finalizeMessages = (messages: ChatMessage[]) => + messages.map(message => + message.pending + ? { + ...message, + parts: chatMessageText(message).trim() + ? appendTextPart(message.parts, INTERRUPTED_MARKER) + : [...message.parts, textPart(INTERRUPTED_MARKER.trim())], + pending: false + } + : message + ) + + if (!sessionId) { + setMessages(finalizeMessages($messages.get())) + + return + } + + updateSessionState(sessionId, state => { + const streamId = state.streamId + + const messages = streamId + ? state.messages.map(message => + message.id === streamId + ? { + ...message, + parts: chatMessageText(message).trim() + ? appendTextPart(message.parts, INTERRUPTED_MARKER) + : [...message.parts, textPart(INTERRUPTED_MARKER.trim())], + pending: false + } + : message + ) + : finalizeMessages(state.messages) + + return { + ...state, + messages, + busy: false, + awaitingResponse: false, + streamId: null, + pendingBranchGroup: null, + interrupted: true + } + }) + + try { + await requestGateway('session.interrupt', { session_id: sessionId }) + } catch (err) { + notifyError(err, 'Stop failed') + } + }, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState]) + + const reloadFromMessage = useCallback( + async (parentId: string | null) => { + if (!activeSessionId || $busy.get()) { + return + } + + const messages = $messages.get() + const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1 + + const userIndex = + parentIndex >= 0 + ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user') + : -1 + + if (userIndex < 0) { + return + } + + const absoluteUserIndex = parentIndex - userIndex + const userMessage = messages[absoluteUserIndex] + const userText = userMessage ? chatMessageText(userMessage).trim() : '' + + if (!userText) { + return + } + + const targetAssistant = + parentId && messages[parentIndex]?.role === 'assistant' + ? messages[parentIndex] + : messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant') + + const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage) + const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex) + + clearNotifications() + updateSessionState(activeSessionId, state => { + const nextUserIndex = state.messages.findIndex( + (message, index) => index > absoluteUserIndex && message.role === 'user' + ) + + const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex + + return { + ...state, + busy: true, + awaitingResponse: true, + pendingBranchGroup: branchGroupId, + sawAssistantPayload: false, + interrupted: false, + messages: [ + ...state.messages.slice(0, absoluteUserIndex + 1), + ...state.messages + .slice(absoluteUserIndex + 1, end) + .map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message)) + ] + } + }) + + try { + await requestGateway('prompt.submit', { + session_id: activeSessionId, + text: userText, + truncate_before_user_ordinal: truncateBeforeUserOrdinal + }) + } catch (err) { + updateSessionState(activeSessionId, state => ({ + ...state, + busy: false, + awaitingResponse: false + })) + notifyError(err, 'Regenerate failed') + } + }, + [activeSessionId, requestGateway, updateSessionState] + ) + + const editMessage = useCallback( + async (edited: AppendMessage) => { + const sessionId = activeSessionId || activeSessionIdRef.current + const sourceId = edited.sourceId || edited.parentId + const text = appendText(edited) + + if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) { + return + } + + const messages = $messages.get() + const sourceIndex = messages.findIndex(m => m.id === sourceId) + const source = messages[sourceIndex] + + if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) { + return + } + + // Failed turn: optimistic user msg never reached the gateway, so truncating + // by ordinal would 422. Submit as a plain resend instead. + const nextMessage = messages[sourceIndex + 1] + const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error) + const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] } + + clearNotifications() + busyRef.current = true + setBusy(true) + setAwaitingResponse(true) + updateSessionState(sessionId, state => ({ + ...state, + busy: true, + awaitingResponse: true, + pendingBranchGroup: null, + sawAssistantPayload: false, + interrupted: false, + messages: [...state.messages.slice(0, sourceIndex), editedMessage] + })) + + const submit = (truncateOrdinal?: number) => + requestGateway('prompt.submit', { + session_id: sessionId, + text, + ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) + }) + + const isStaleTargetError = (err: unknown) => + /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) + + try { + await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)) + } catch (err) { + let surfaced = err + + if (!isFailedTurn && isStaleTargetError(err)) { + try { + await submit() + + return + } catch (retryErr) { + surfaced = retryErr + } + } + + busyRef.current = false + setBusy(false) + setAwaitingResponse(false) + updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false })) + notifyError(surfaced, 'Edit failed') + } + }, + [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState] + ) + + const handleThreadMessagesChange = useCallback( + (nextMessages: readonly ThreadMessage[]) => { + const visibleIds = new Set(nextMessages.map(m => m.id)) + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + return + } + + updateSessionState(sessionId, state => { + let changed = false + + const messages = state.messages.map(message => { + if (message.role !== 'assistant' || !message.branchGroupId) { + return message + } + + const hidden = !visibleIds.has(message.id) + + if (message.hidden === hidden) { + return message + } + + changed = true + + return { ...message, hidden } + }) + + return changed ? { ...state, messages } : state + }) + }, + [activeSessionIdRef, updateSessionState] + ) + + return { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } +} diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx new file mode 100644 index 000000000..d0f14f13b --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useRouteResume } from './use-route-resume' + +interface HarnessProps { + activeSessionId: null | string + activeSessionIdRef: MutableRefObject<null | string> + creatingSessionRef: MutableRefObject<boolean> + currentView: string + freshDraftReady: boolean + gatewayState: string + locationPathname: string + resumeSession: (sessionId: string, focus: boolean) => Promise<unknown> + routedSessionId: null | string + runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> + selectedStoredSessionId: null | string + selectedStoredSessionIdRef: MutableRefObject<null | string> + startFreshSessionDraft: (focus: boolean) => unknown +} + +function RouteResumeHarness(props: HarnessProps) { + useRouteResume(props) + + return null +} + +describe('useRouteResume', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('does not re-resume the old session during a /:sid -> /new transition', () => { + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' } + const creatingSessionRef = { current: false } + const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) } + const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' } + + const { rerender } = render( + <RouteResumeHarness + activeSessionId="runtime-1" + activeSessionIdRef={activeSessionIdRef} + creatingSessionRef={creatingSessionRef} + currentView="chat" + freshDraftReady={false} + gatewayState="open" + locationPathname="/session-1" + resumeSession={resumeSession} + routedSessionId="session-1" + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + selectedStoredSessionId="session-1" + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + startFreshSessionDraft={startFreshSessionDraft} + /> + ) + + expect(resumeSession).not.toHaveBeenCalled() + + // Simulate startFreshSessionDraft state updates landing before route update. + activeSessionIdRef.current = null + selectedStoredSessionIdRef.current = null + rerender( + <RouteResumeHarness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + creatingSessionRef={creatingSessionRef} + currentView="chat" + freshDraftReady + gatewayState="open" + locationPathname="/session-1" + resumeSession={resumeSession} + routedSessionId="session-1" + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + selectedStoredSessionId={null} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + startFreshSessionDraft={startFreshSessionDraft} + /> + ) + + expect(resumeSession).not.toHaveBeenCalled() + }) + + it('resumes when pathname changes to a routed session', () => { + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const activeSessionIdRef: MutableRefObject<null | string> = { current: null } + const creatingSessionRef = { current: false } + const runtimeIdByStoredSessionIdRef = { current: new Map() } + const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: null } + + const { rerender } = render( + <RouteResumeHarness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + creatingSessionRef={creatingSessionRef} + currentView="chat" + freshDraftReady + gatewayState="open" + locationPathname="/" + resumeSession={resumeSession} + routedSessionId={null} + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + selectedStoredSessionId={null} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + startFreshSessionDraft={startFreshSessionDraft} + /> + ) + + expect(resumeSession).not.toHaveBeenCalled() + + rerender( + <RouteResumeHarness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + creatingSessionRef={creatingSessionRef} + currentView="chat" + freshDraftReady + gatewayState="open" + locationPathname="/session-2" + resumeSession={resumeSession} + routedSessionId="session-2" + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + selectedStoredSessionId={null} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + startFreshSessionDraft={startFreshSessionDraft} + /> + ) + + expect(resumeSession).toHaveBeenCalledTimes(1) + expect(resumeSession).toHaveBeenCalledWith('session-2', true) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts new file mode 100644 index 000000000..9f6fc5e3d --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -0,0 +1,115 @@ +import { type MutableRefObject, useEffect, useRef } from 'react' + +import { isNewChatRoute } from '@/app/routes' + +interface RouteResumeOptions { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject<string | null> + creatingSessionRef: MutableRefObject<boolean> + currentView: string + freshDraftReady: boolean + gatewayState: string | undefined + locationPathname: string + resumeSession: (sessionId: string, focus: boolean) => Promise<unknown> + routedSessionId: string | null + runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> + selectedStoredSessionId: string | null + selectedStoredSessionIdRef: MutableRefObject<string | null> + startFreshSessionDraft: (focus: boolean) => unknown +} + +// HashRouter boot edge case: pathname briefly reads `/` before the hash is +// parsed. If the hash references a real session, defer; resume picks it up +// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states. +function rawHashLooksLikeSession(): boolean { + if (typeof window === 'undefined') { + return false + } + + const hash = window.location.hash.replace(/^#/, '') + + if (!hash || hash === '/') { + return false + } + + return ( + !hash.startsWith('/settings') && + !hash.startsWith('/skills') && + !hash.startsWith('/messaging') && + !hash.startsWith('/artifacts') + ) +} + +export function useRouteResume({ + activeSessionId, + activeSessionIdRef, + creatingSessionRef, + currentView, + freshDraftReady, + gatewayState, + locationPathname, + resumeSession, + routedSessionId, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft +}: RouteResumeOptions) { + const lastPathnameRef = useRef<string | null>(null) + const wasGatewayOpenRef = useRef(false) + + useEffect(() => { + const gatewayOpen = gatewayState === 'open' + const pathnameChanged = lastPathnameRef.current !== locationPathname + const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen + lastPathnameRef.current = locationPathname + wasGatewayOpenRef.current = gatewayOpen + + if (currentView !== 'chat' || !gatewayOpen) { + return + } + + if (routedSessionId) { + const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId) + + const alreadyActive = + routedSessionId === selectedStoredSessionIdRef.current && + Boolean(cachedRuntime) && + cachedRuntime === activeSessionIdRef.current + + // Resume only when the route meaningfully changed (or gateway just opened). + // This avoids a transient /:sid re-resume during "new chat" state clears + // before the pathname updates from /:sid -> /. + const shouldResume = pathnameChanged || gatewayBecameOpen + + if (!alreadyActive && shouldResume && !creatingSessionRef.current) { + void resumeSession(routedSessionId, true) + } + + return + } + + if ( + isNewChatRoute(locationPathname) && + !creatingSessionRef.current && + (selectedStoredSessionId || activeSessionId || !freshDraftReady) && + !rawHashLooksLikeSession() + ) { + startFreshSessionDraft(true) + } + }, [ + activeSessionId, + activeSessionIdRef, + creatingSessionRef, + currentView, + freshDraftReady, + gatewayState, + locationPathname, + resumeSession, + routedSessionId, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft + ]) +} diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts new file mode 100644 index 000000000..c470263b9 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -0,0 +1,764 @@ +import type { MutableRefObject } from 'react' +import { useCallback, useRef } from 'react' +import type { NavigateFunction } from 'react-router-dom' + +import { deleteSession, getSessionMessages } from '@/hermes' +import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' +import { normalizePersonalityValue } from '@/lib/chat-runtime' +import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' +import { clearComposerAttachments, clearComposerDraft } from '@/store/composer' +import { clearQueuedPrompts } from '@/store/composer-queue' +import { $pinnedSessionIds } from '@/store/layout' +import { clearNotifications, notify, notifyError } from '@/store/notifications' +import { requestDesktopOnboarding } from '@/store/onboarding' +import { + $currentCwd, + $messages, + $sessions, + setActiveSessionId, + setAwaitingResponse, + setBusy, + setCurrentBranch, + setCurrentCwd, + setCurrentFastMode, + setCurrentModel, + setCurrentPersonality, + setCurrentProvider, + setCurrentReasoningEffort, + setCurrentServiceTier, + setCurrentUsage, + setFreshDraftReady, + setIntroSeed, + setMessages, + setSelectedStoredSessionId, + setSessions, + setSessionStartedAt, + setTurnStartedAt +} from '@/store/session' +import { reportBackendContract } from '@/store/updates' +import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes' + +import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes' +import type { ClientSessionState, SidebarNavItem } from '../../types' + +interface SessionActionsOptions { + activeSessionId: string | null + activeSessionIdRef: MutableRefObject<string | null> + busyRef: MutableRefObject<boolean> + creatingSessionRef: MutableRefObject<boolean> + ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState + getRouteToken: () => string + navigate: NavigateFunction + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> + runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> + selectedStoredSessionId: string | null + selectedStoredSessionIdRef: MutableRefObject<string | null> + sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> + syncSessionStateToView: (sessionId: string, state: ClientSessionState) => void + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => ClientSessionState +} + +function withAppendedText(message: ChatMessage, suffix: string): ChatMessage { + let appended = false + + const parts = message.parts.map(part => { + if (part.type !== 'text' || appended) { + return part + } + + appended = true + + return { ...part, text: `${part.text}${suffix}` } + }) + + return appended ? { ...message, parts } : message +} + +function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage { + if (message.parts.some(part => part.type === 'reasoning')) { + return message + } + + const reasoningParts = previous.parts.filter(part => part.type === 'reasoning') + + return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message +} + +function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean { + if ( + a.id !== b.id || + a.role !== b.role || + a.pending !== b.pending || + a.error !== b.error || + a.hidden !== b.hidden || + a.branchGroupId !== b.branchGroupId + ) { + return false + } + + if (a.parts.length !== b.parts.length) { + return false + } + + return a.parts.every((part, index) => JSON.stringify(part) === JSON.stringify(b.parts[index])) +} + +function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean { + return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index])) +} + +function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: ChatMessage[]): ChatMessage[] { + if (!previousMessages.length) { + return nextMessages + } + + const previousByRoleOrdinal = new Map<string, ChatMessage>() + const previousRoleCounts = new Map<string, number>() + + for (const message of previousMessages) { + const ordinal = previousRoleCounts.get(message.role) ?? 0 + previousRoleCounts.set(message.role, ordinal + 1) + previousByRoleOrdinal.set(`${message.role}:${ordinal}`, message) + } + + const nextRoleCounts = new Map<string, number>() + + return nextMessages.map(message => { + const ordinal = nextRoleCounts.get(message.role) ?? 0 + nextRoleCounts.set(message.role, ordinal + 1) + + const previous = previousByRoleOrdinal.get(`${message.role}:${ordinal}`) + + if (!previous) { + return message + } + + const nextText = chatMessageText(message).trim() + const previousText = chatMessageText(previous) + const previousVisibleText = textWithoutEmbeddedImages(previousText) + let preserved = message + + if (nextText === previousVisibleText || nextText === previousText.trim()) { + preserved = preserveReasoningParts(preserved, previous) + } + + const previousImages = embeddedImageUrls(previousText) + + if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) { + return preserved + } + + if (nextText !== previousVisibleText) { + return preserved + } + + return withAppendedText(preserved, previousImages.map(url => `\n${url}`).join('')) + }) +} + +function upsertOptimisticSession( + created: SessionCreateResponse, + id: string, + title: string | null = null, + preview: string | null = null +) { + const now = Date.now() / 1000 + + const session: SessionInfo = { + cwd: created.info?.cwd ?? null, + ended_at: null, + id, + input_tokens: 0, + is_active: true, + last_active: now, + message_count: created.message_count ?? created.messages?.length ?? 0, + model: created.info?.model ?? null, + output_tokens: 0, + preview, + source: 'tui', + started_at: now, + title, + tool_call_count: 0 + } + + setSessions(prev => [session, ...prev.filter(s => s.id !== id)]) +} + +function patchSessionWorkspace(sessionId: string, cwd: string | undefined) { + if (!cwd) { + return + } + + setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) +} + +function applyRuntimeInfo( + info: SessionCreateResponse['info'] | undefined +): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null { + if (!info) { + return null + } + + const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {} + + reportBackendContract(info.desktop_contract) + + if (info.credential_warning) { + requestDesktopOnboarding(info.credential_warning) + } + + if (info.model) { + setCurrentModel(info.model) + } + + if (info.provider) { + setCurrentProvider(info.provider) + } + + if (info.cwd) { + setCurrentCwd(info.cwd) + sessionState.cwd = info.cwd + } + + if (info.branch !== undefined) { + setCurrentBranch(info.branch || '') + sessionState.branch = info.branch || '' + } + + if (typeof info.personality === 'string') { + setCurrentPersonality(normalizePersonalityValue(info.personality)) + } + + if (typeof info.reasoning_effort === 'string') { + setCurrentReasoningEffort(info.reasoning_effort) + } + + if (typeof info.service_tier === 'string') { + setCurrentServiceTier(info.service_tier) + } + + if (typeof info.fast === 'boolean') { + setCurrentFastMode(info.fast) + } + + if (info.usage) { + setCurrentUsage(current => ({ ...current, ...info.usage })) + } + + return sessionState +} + +export function useSessionActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState +}: SessionActionsOptions) { + const resumeRequestRef = useRef(0) + + const startFreshSessionDraft = useCallback( + (replaceRoute = false) => { + busyRef.current = false + setBusy(false) + setAwaitingResponse(false) + clearNotifications() + setIntroSeed(seed => seed + 1) + navigate(NEW_CHAT_ROUTE, { replace: replaceRoute }) + setActiveSessionId(null) + activeSessionIdRef.current = null + setSelectedStoredSessionId(null) + selectedStoredSessionIdRef.current = null + setMessages([]) + setCurrentUsage({ + calls: 0, + input: 0, + output: 0, + total: 0 + }) + setSessionStartedAt(null) + setTurnStartedAt(null) + setCurrentCwd('') + setCurrentBranch('') + clearComposerDraft() + clearComposerAttachments() + setFreshDraftReady(true) + }, + [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] + ) + + const createBackendSessionForSend = useCallback(async (): Promise<string | null> => { + const startingActiveSessionId = activeSessionIdRef.current + const startingStoredSessionId = selectedStoredSessionIdRef.current + const startingRouteToken = getRouteToken() + + creatingSessionRef.current = true + + try { + const cwd = $currentCwd.get().trim() + const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) }) + const stored = created.stored_session_id ?? null + + if ( + activeSessionIdRef.current !== startingActiveSessionId || + selectedStoredSessionIdRef.current !== startingStoredSessionId || + getRouteToken() !== startingRouteToken + ) { + await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined) + + return null + } + + activeSessionIdRef.current = created.session_id + selectedStoredSessionIdRef.current = stored + ensureSessionState(created.session_id, stored) + + if (stored) { + upsertOptimisticSession(created, stored) + navigate(sessionRoute(stored), { replace: true }) + } + + setFreshDraftReady(false) + setActiveSessionId(created.session_id) + setSelectedStoredSessionId(stored) + setSessionStartedAt(Date.now()) + const runtimeInfo = applyRuntimeInfo(created.info) + + if (runtimeInfo) { + updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored) + } + + return created.session_id + } finally { + window.setTimeout(() => { + creatingSessionRef.current = false + }, 0) + } + }, [ + activeSessionIdRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + selectedStoredSessionIdRef, + updateSessionState + ]) + + const selectSidebarItem = useCallback( + (item: SidebarNavItem) => { + if (item.action === 'new-session') { + startFreshSessionDraft() + + return + } + + if (item.route) { + navigate(item.route) + } + }, + [navigate, startFreshSessionDraft] + ) + + const openSettings = useCallback(() => { + navigate(SETTINGS_ROUTE) + }, [navigate]) + + const closeSettings = useCallback(() => { + if (selectedStoredSessionId) { + navigate(sessionRoute(selectedStoredSessionId)) + + return + } + + navigate(NEW_CHAT_ROUTE) + }, [navigate, selectedStoredSessionId]) + + const resumeSession = useCallback( + async (storedSessionId: string, replaceRoute = false) => { + const requestId = resumeRequestRef.current + 1 + resumeRequestRef.current = requestId + + const isCurrentResume = () => + resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId + + const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) + + if (cachedRuntimeId && cachedState) { + setFreshDraftReady(false) + clearNotifications() + setSelectedStoredSessionId(storedSessionId) + selectedStoredSessionIdRef.current = storedSessionId + setActiveSessionId(cachedRuntimeId) + activeSessionIdRef.current = cachedRuntimeId + syncSessionStateToView(cachedRuntimeId, cachedState) + setCurrentCwd(cachedState.cwd) + setCurrentBranch(cachedState.branch) + setSessionStartedAt(Date.now()) + clearComposerDraft() + clearComposerAttachments() + + void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId }) + .then(usage => { + if (isCurrentResume() && usage) { + setCurrentUsage(current => ({ ...current, ...usage })) + } + }) + .catch(() => undefined) + + return + } + + setFreshDraftReady(false) + setActiveSessionId(null) + activeSessionIdRef.current = null + busyRef.current = true + setBusy(true) + setAwaitingResponse(false) + clearNotifications() + setSelectedStoredSessionId(storedSessionId) + selectedStoredSessionIdRef.current = storedSessionId + setSessionStartedAt(Date.now()) + const stored = $sessions.get().find(session => session.id === storedSessionId) + + if (stored) { + setCurrentUsage(current => ({ + ...current, + input: stored.input_tokens || 0, + output: stored.output_tokens || 0, + total: (stored.input_tokens || 0) + (stored.output_tokens || 0) + })) + } + + try { + // Load the local snapshot first, then ask the gateway to resume. + // Previously these raced: + // 1. clear messages to [] + // 2. local getSessionMessages -> 45 msgs + // 3. a second resume path cleared [] again + // 4. gateway resume -> 43 msgs + // That is the ctrl+R flash chain. Avoid showing an empty thread + // while we already have a route-scoped session id, and don't race the + // local snapshot against gateway resume. + let localSnapshot = $messages.get() + + try { + const storedMessages = await getSessionMessages(storedSessionId) + + if (isCurrentResume()) { + localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get()) + + if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { + setMessages(localSnapshot) + } + } + } catch { + // Non-fatal: gateway resume below can still hydrate the session. + } + + const resumed = await requestGateway<SessionResumeResponse>('session.resume', { + session_id: storedSessionId, + cols: 96 + }) + + if (!isCurrentResume()) { + return + } + + const currentMessages = $messages.get() + + const resumedMessages = preserveLocalAssistantErrors( + reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages), + currentMessages + ) + // Avoid a second visible transcript rebuild on resume/switch. + // `getSessionMessages()` is the stable stored transcript snapshot and + // paints first; `session.resume` can return a slightly different + // runtime-shaped projection (e.g. tool/system coalescing), which was + // causing a second full message-list replacement a second later. + // Keep the already-painted local snapshot for the view/cache when it + // exists; use gateway messages only as a fallback when no local + // snapshot was available. + + const preferredMessages = + localSnapshot.length > 0 + ? localSnapshot + : chatMessageArraysEquivalent(currentMessages, resumedMessages) + ? currentMessages + : resumedMessages + + const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages) + + setActiveSessionId(resumed.session_id) + activeSessionIdRef.current = resumed.session_id + const runtimeInfo = applyRuntimeInfo(resumed.info) + + patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd) + + updateSessionState( + resumed.session_id, + state => ({ + ...state, + ...(runtimeInfo ?? {}), + messages: messagesForView, + busy: false, + awaitingResponse: false + }), + storedSessionId + ) + clearComposerDraft() + clearComposerAttachments() + } catch (err) { + if (!isCurrentResume()) { + return + } + + const fallback = await getSessionMessages(storedSessionId) + + if (!isCurrentResume()) { + return + } + + setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get())) + notifyError(err, 'Resume failed') + } finally { + if (isCurrentResume()) { + busyRef.current = false + setBusy(false) + setAwaitingResponse(false) + } + } + }, + [ + activeSessionIdRef, + busyRef, + requestGateway, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + ] + ) + + const branchCurrentSession = useCallback( + async (messageId?: string): Promise<boolean> => { + const sourceSessionId = activeSessionIdRef.current + + if (!sourceSessionId) { + notify({ + kind: 'warning', + title: 'Nothing to branch', + message: 'Start or resume a chat before branching.' + }) + + return false + } + + if (busyRef.current) { + notify({ + kind: 'warning', + title: 'Session busy', + message: 'Stop the current turn before branching this chat.' + }) + + return false + } + + creatingSessionRef.current = true + + try { + const currentMessages = $messages.get() + + const targetIndex = messageId + ? currentMessages.findIndex(message => message.id === messageId) + : currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user') + + const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0) + const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length + + const branchMessages = currentMessages + .slice(branchStart, branchEnd) + .map(message => ({ + content: chatMessageText(message), + source: message, + role: message.role + })) + .filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role)) + + if (!branchMessages.length) { + notify({ + kind: 'warning', + title: 'Nothing to branch', + message: 'This message has no text to branch from.' + }) + + return false + } + + clearNotifications() + + const cwd = $currentCwd.get().trim() + + const branched = await requestGateway<SessionCreateResponse>('session.create', { + cols: 96, + ...(cwd && { cwd }), + messages: branchMessages.map(({ content, role }) => ({ content, role })), + title: 'Branch' + }) + + const routedSessionId = branched.stored_session_id ?? branched.session_id + const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null + + setFreshDraftReady(false) + upsertOptimisticSession(branched, routedSessionId, 'Branch', preview) + ensureSessionState(branched.session_id, routedSessionId) + setActiveSessionId(branched.session_id) + activeSessionIdRef.current = branched.session_id + updateSessionState( + branched.session_id, + state => ({ + ...state, + messages: branchMessages.map(({ source }) => source), + busy: false, + awaitingResponse: false + }), + routedSessionId + ) + setSelectedStoredSessionId(routedSessionId) + selectedStoredSessionIdRef.current = routedSessionId + navigate(sessionRoute(routedSessionId)) + + clearComposerDraft() + clearComposerAttachments() + const runtimeInfo = applyRuntimeInfo(branched.info) + + patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd) + + if (runtimeInfo) { + updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId) + } + + return true + } catch (err) { + notifyError(err, 'Branch failed') + + return false + } finally { + window.setTimeout(() => { + creatingSessionRef.current = false + }, 0) + } + }, + [ + activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState, + navigate, + requestGateway, + selectedStoredSessionIdRef, + updateSessionState + ] + ) + + const removeSession = useCallback( + async (storedSessionId: string) => { + clearNotifications() + + const removed = $sessions.get().find(s => s.id === storedSessionId) + const wasSelected = selectedStoredSessionId === storedSessionId + const closingRuntimeId = wasSelected ? activeSessionId : null + const previousMessages = $messages.get() + const previousPinned = $pinnedSessionIds.get() + + setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId)) + + // Tear down before awaiting so the route effect can't resume the + // doomed session via the stale /<sid> URL. + if (wasSelected) { + startFreshSessionDraft(true) + } + + try { + if (closingRuntimeId) { + await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined) + } + + await deleteSession(storedSessionId) + clearQueuedPrompts(storedSessionId) + + if (closingRuntimeId) { + clearQueuedPrompts(closingRuntimeId) + } + } catch (err) { + if (removed) { + setSessions(prev => [removed, ...prev]) + } + + $pinnedSessionIds.set(previousPinned) + + if (wasSelected) { + setFreshDraftReady(false) + setSelectedStoredSessionId(storedSessionId) + selectedStoredSessionIdRef.current = storedSessionId + const stored = $sessions.get().find(session => session.id === storedSessionId) + + if (stored) { + setCurrentUsage(current => ({ + ...current, + input: stored.input_tokens || 0, + output: stored.output_tokens || 0, + total: (stored.input_tokens || 0) + (stored.output_tokens || 0) + })) + } + + setMessages(previousMessages) + navigate(sessionRoute(storedSessionId), { replace: true }) + + if (closingRuntimeId) { + setActiveSessionId(closingRuntimeId) + activeSessionIdRef.current = closingRuntimeId + } + } + + notifyError(err, 'Delete failed') + } + }, + [ + activeSessionId, + activeSessionIdRef, + navigate, + requestGateway, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft + ] + ) + + return { + branchCurrentSession, + closeSettings, + createBackendSessionForSend, + openSettings, + removeSession, + resumeSession, + selectSidebarItem, + startFreshSessionDraft + } +} diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts new file mode 100644 index 000000000..398c3c932 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -0,0 +1,159 @@ +import { useStore } from '@nanostores/react' +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' +import { preserveLocalAssistantErrors } from '@/lib/chat-messages' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $busy, $messages, setSessionWorking } from '@/store/session' + +import type { ClientSessionState } from '../../types' + +interface SessionStateCacheOptions { + activeSessionId: string | null + busyRef: MutableRefObject<boolean> + selectedStoredSessionId: string | null + setAwaitingResponse: (awaiting: boolean) => void + setBusy: (busy: boolean) => void + setMessages: (messages: ChatMessage[]) => void +} + +export function useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages +}: SessionStateCacheOptions) { + const busy = useStore($busy) + const activeSessionIdRef = useRef<string | null>(null) + const selectedStoredSessionIdRef = useRef<string | null>(null) + const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>()) + const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>()) + const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null) + const viewSyncRafRef = useRef<number | null>(null) + + useEffect(() => { + activeSessionIdRef.current = activeSessionId + }, [activeSessionId]) + + useEffect(() => { + busyRef.current = busy + }, [busy, busyRef]) + + useEffect(() => { + selectedStoredSessionIdRef.current = selectedStoredSessionId + }, [selectedStoredSessionId]) + + const ensureSessionState = useCallback((sessionId: string, storedSessionId?: string | null) => { + const existing = sessionStateByRuntimeIdRef.current.get(sessionId) + + if (existing) { + if (storedSessionId !== undefined) { + const previousStoredSessionId = existing.storedSessionId + existing.storedSessionId = storedSessionId + + if (storedSessionId) { + runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId) + + if (existing.busy) { + setSessionWorking(storedSessionId, true) + } + } + + if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) { + setSessionWorking(previousStoredSessionId, false) + } + } + + return existing + } + + const created = createClientSessionState(storedSessionId ?? null) + sessionStateByRuntimeIdRef.current.set(sessionId, created) + + if (storedSessionId) { + runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId) + } + + return created + }, []) + + const flushPendingViewState = useCallback(() => { + const pending = pendingViewStateRef.current + pendingViewStateRef.current = null + + if (!pending || pending.sessionId !== activeSessionIdRef.current) { + return + } + + setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get())) + setBusy(pending.state.busy) + busyRef.current = pending.state.busy + setAwaitingResponse(pending.state.awaitingResponse) + }, [busyRef, setAwaitingResponse, setBusy, setMessages]) + + const syncSessionStateToView = useCallback( + (sessionId: string, state: ClientSessionState) => { + pendingViewStateRef.current = { sessionId, state } + + if (viewSyncRafRef.current !== null) { + return + } + + if (typeof window === 'undefined') { + flushPendingViewState() + + return + } + + viewSyncRafRef.current = window.requestAnimationFrame(() => { + viewSyncRafRef.current = null + flushPendingViewState() + }) + }, + [flushPendingViewState] + ) + + useEffect( + () => () => { + if (viewSyncRafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(viewSyncRafRef.current) + viewSyncRafRef.current = null + } + }, + [] + ) + + const updateSessionState = useCallback( + ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null + ) => { + const previous = ensureSessionState(sessionId, storedSessionId) + const next = updater({ ...previous, messages: previous.messages }) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + if (previous.storedSessionId !== next.storedSessionId || !next.busy) { + setSessionWorking(previous.storedSessionId, false) + } + + setSessionWorking(next.storedSessionId, next.busy) + syncSessionStateToView(sessionId, next) + + return next + }, + [ensureSessionState, syncSessionStateToView] + ) + + return { + activeSessionIdRef, + ensureSessionState, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + } +} diff --git a/apps/desktop/src/app/settings/about-settings.tsx b/apps/desktop/src/app/settings/about-settings.tsx new file mode 100644 index 000000000..e178b998d --- /dev/null +++ b/apps/desktop/src/app/settings/about-settings.tsx @@ -0,0 +1,167 @@ +import { useStore } from '@nanostores/react' +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $desktopVersion, + $updateApply, + $updateChecking, + $updateStatus, + checkUpdates, + openUpdatesWindow +} from '@/store/updates' + +import { ListRow, SectionHeading, SettingsContent } from './primitives' + +const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases' + +function relativeTime(ms: number | undefined) { + if (!ms) { + return 'never' + } + + const diff = Date.now() - ms + + if (diff < 60_000) { + return 'just now' + } + + if (diff < 3_600_000) { + return `${Math.round(diff / 60_000)} min ago` + } + + if (diff < 86_400_000) { + return `${Math.round(diff / 3_600_000)} hours ago` + } + + return `${Math.round(diff / 86_400_000)} days ago` +} + +export function AboutSettings() { + const version = useStore($desktopVersion) + const status = useStore($updateStatus) + const apply = useStore($updateApply) + const checking = useStore($updateChecking) + const [justChecked, setJustChecked] = useState(false) + + const behind = status?.behind ?? 0 + const supported = status?.supported !== false + const applying = apply.applying || apply.stage === 'restart' + + const handleCheck = async () => { + setJustChecked(false) + const next = await checkUpdates() + setJustChecked(Boolean(next)) + } + + let statusLine: string + let statusTone: 'idle' | 'available' | 'error' = 'idle' + + if (!supported) { + statusLine = status?.message ?? "This build can't update itself from inside the app." + statusTone = 'error' + } else if (status?.error) { + statusLine = "We couldn't reach the update server." + statusTone = 'error' + } else if (applying) { + statusLine = 'An update is currently installing.' + statusTone = 'available' + } else if (behind > 0) { + statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).` + statusTone = 'available' + } else if (status) { + statusLine = "You're on the latest version." + } else { + statusLine = 'Tap "Check now" to look for updates.' + } + + return ( + <SettingsContent> + <div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center"> + <span className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary"> + <Sparkles className="size-8" /> + </span> + <div> + <h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2> + <p className="mt-1 text-xs text-muted-foreground"> + {version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'} + </p> + </div> + </div> + + <div className="mx-auto mt-4 w-full max-w-2xl"> + <SectionHeading icon={RefreshCw} title="Updates" /> + + <div + className={cn( + 'rounded-xl border px-4 py-3 text-sm', + statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground', + statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive', + statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground' + )} + > + <div className="flex items-start gap-2"> + {statusTone === 'available' ? ( + <Sparkles className="mt-0.5 size-4 shrink-0 text-primary" /> + ) : statusTone === 'error' ? null : ( + <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" /> + )} + <div className="min-w-0"> + <p className="font-medium">{statusLine}</p> + <p className="mt-1 text-xs text-muted-foreground"> + Last checked {relativeTime(status?.fetchedAt)} + {justChecked && !checking ? ' · just now' : ''} + </p> + </div> + </div> + + <div className="mt-3 flex flex-wrap items-center gap-2"> + <Button + disabled={checking || applying || !supported} + onClick={() => void handleCheck()} + size="sm" + variant="outline" + > + {checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} + {checking ? 'Checking…' : 'Check now'} + </Button> + + {behind > 0 && supported && !applying && ( + <Button onClick={() => openUpdatesWindow()} size="sm"> + See what's new + </Button> + )} + + <Button + asChild + className="ml-auto text-xs text-muted-foreground hover:text-foreground" + size="sm" + variant="ghost" + > + <a + href={RELEASE_NOTES_URL} + onClick={event => { + event.preventDefault() + void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL) + }} + rel="noreferrer" + target="_blank" + > + <ExternalLink className="size-3" /> + Release notes + </a> + </Button> + </div> + </div> + + <ListRow + description="Hermes checks for updates automatically in the background and lets you know when one is ready." + hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`} + title="Automatic updates" + /> + </div> + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx new file mode 100644 index 000000000..c35ec3417 --- /dev/null +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -0,0 +1,225 @@ +import { useStore } from '@nanostores/react' + +import { triggerHaptic } from '@/lib/haptics' +import { Check, Palette } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $toolViewMode, setToolViewMode } from '@/store/tool-view' +import { useTheme } from '@/themes/context' +import { BUILTIN_THEMES } from '@/themes/presets' + +import { MODE_OPTIONS } from './constants' +import { prettyName } from './helpers' +import { Pill, SectionHeading, SettingsContent } from './primitives' + +function ThemePreview({ name }: { name: string }) { + const t = BUILTIN_THEMES[name] + + if (!t) { + return null + } + + const c = t.colors + + return ( + <div + className="h-20 overflow-hidden rounded-xl border shadow-xs" + style={{ backgroundColor: c.background, borderColor: c.border }} + > + <div className="flex h-full"> + <div + className="w-12 border-r" + style={{ + backgroundColor: c.sidebarBackground ?? c.muted, + borderColor: c.sidebarBorder ?? c.border + }} + /> + <div className="flex flex-1 flex-col gap-2 p-3"> + <div className="h-2.5 w-16 rounded-full" style={{ backgroundColor: c.foreground }} /> + <div className="h-2 w-24 rounded-full" style={{ backgroundColor: c.mutedForeground }} /> + <div className="mt-auto flex justify-end"> + <div + className="h-5 w-16 rounded-full border" + style={{ + backgroundColor: c.userBubble ?? c.muted, + borderColor: c.userBubbleBorder ?? c.border + }} + /> + </div> + </div> + </div> + </div> + ) +} + +export function AppearanceSettings() { + const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() + const toolViewMode = useStore($toolViewMode) + const activeTheme = availableThemes.find(t => t.name === themeName) + + return ( + <SettingsContent> + <div className="space-y-5"> + <div> + <SectionHeading icon={Palette} title="Appearance" /> + <p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and + chat surface styling. + </p> + </div> + + <section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm"> + <div className="mb-3 flex items-center justify-between gap-3"> + <div> + <div className="text-sm font-medium">Color Mode</div> + <div className="mt-1 text-xs text-muted-foreground"> + Pick a fixed mode or let Hermes follow your system setting. + </div> + </div> + <Pill>{prettyName(mode)}</Pill> + </div> + <div className="grid gap-2 sm:grid-cols-3"> + {MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => { + const active = mode === id + + return ( + <button + className={cn( + 'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)', + active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)' + )} + key={id} + onClick={() => { + triggerHaptic('crisp') + setMode(id) + }} + type="button" + > + <div className="flex items-start justify-between gap-3"> + <span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background"> + <Icon className="size-4" /> + </span> + {active && ( + <span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"> + <Check className="size-3.5" /> + </span> + )} + </div> + <div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{label}</div> + <div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {description} + </div> + </button> + ) + })} + </div> + </section> + + <section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm"> + <div className="mb-3 flex items-center justify-between gap-3"> + <div> + <div className="text-sm font-medium">Tool Call Display</div> + <div className="mt-1 text-xs text-muted-foreground"> + Product hides raw tool payloads; Technical shows full input/output. + </div> + </div> + <Pill>{toolViewMode === 'technical' ? 'Technical' : 'Product'}</Pill> + </div> + <div className="grid gap-2 sm:grid-cols-2"> + {( + [ + { + id: 'product', + label: 'Product', + description: 'Human-friendly tool activity with concise summaries.' + }, + { + id: 'technical', + label: 'Technical', + description: 'Include raw tool args/results and low-level details.' + } + ] as const + ).map(option => { + const active = toolViewMode === option.id + + return ( + <button + className={cn( + 'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)', + active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)' + )} + key={option.id} + onClick={() => { + triggerHaptic('selection') + setToolViewMode(option.id) + }} + type="button" + > + <div className="flex items-start justify-between gap-3"> + <div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div> + {active && ( + <span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"> + <Check className="size-3.5" /> + </span> + )} + </div> + <div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {option.description} + </div> + </button> + ) + })} + </div> + </section> + + <section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm"> + <div className="mb-3 flex items-center justify-between gap-3"> + <div> + <div className="text-sm font-medium">Theme</div> + <div className="mt-1 text-xs text-muted-foreground"> + Desktop palettes only. The selected mode is applied on top. + </div> + </div> + {activeTheme && <Pill>{activeTheme.label}</Pill>} + </div> + <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> + {availableThemes.map(theme => { + const active = themeName === theme.name + + return ( + <button + className={cn( + 'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)', + active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)' + )} + key={theme.name} + onClick={() => { + triggerHaptic('crisp') + setTheme(theme.name) + }} + type="button" + > + <ThemePreview name={theme.name} /> + <div className="mt-3 flex items-start justify-between gap-3 px-1"> + <div className="min-w-0"> + <div className="truncate text-[length:var(--conversation-text-font-size)] font-medium"> + {theme.label} + </div> + <div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {theme.description} + </div> + </div> + {active && ( + <span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"> + <Check className="size-3.5" /> + </span> + )} + </div> + </button> + ) + })} + </div> + </section> + </div> + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx new file mode 100644 index 000000000..aab075630 --- /dev/null +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -0,0 +1,360 @@ +import type { ChangeEvent, ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + getElevenLabsVoices, + getHermesConfigDefaults, + getHermesConfigRecord, + getHermesConfigSchema, + saveHermesConfig +} from '@/hermes' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' + +import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' +import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers' +import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' +import type { SearchProps } from './types' + +function ConfigField({ + schemaKey, + schema, + value, + enumOptions, + optionLabels, + onChange +}: { + schemaKey: string + schema: ConfigFieldSchema + value: unknown + enumOptions?: string[] + optionLabels?: Record<string, string> + onChange: (value: unknown) => void +}) { + const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey) + const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '') + const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim() + const normalizedDesc = normalize(rawDescription) + + const description = + rawDescription && normalizedDesc !== normalize(label) && normalizedDesc !== normalize(schemaKey) + ? rawDescription + : undefined + + const row = (action: ReactNode, wide = false) => ( + <ListRow action={action} description={description} title={label} wide={wide} /> + ) + + if (schema.type === 'boolean') { + return row( + <div className="flex items-center justify-end gap-3"> + <span className="text-xs text-muted-foreground">{value ? 'On' : 'Off'}</span> + <Switch checked={Boolean(value)} onCheckedChange={onChange} /> + </div> + ) + } + + const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined) + + if (selectOptions) { + return row( + <Select + onValueChange={next => onChange(next === EMPTY_SELECT_VALUE ? '' : next)} + value={String(value ?? '') || EMPTY_SELECT_VALUE} + > + <SelectTrigger className={CONTROL_TEXT}> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {selectOptions.map(option => ( + <SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}> + {option + ? (optionLabels?.[option] ?? prettyName(option)) + : schemaKey === 'display.personality' + ? 'None' + : '(none)'} + </SelectItem> + ))} + </SelectContent> + </Select> + ) + } + + if (schema.type === 'number') { + return row( + <Input + className={cn('h-8', CONTROL_TEXT)} + onChange={e => { + const raw = e.target.value + const n = raw === '' ? 0 : Number(raw) + + if (!Number.isNaN(n)) { + onChange(n) + } + }} + placeholder="Not set" + type="number" + value={value === undefined || value === null ? '' : String(value)} + /> + ) + } + + if (schema.type === 'list') { + return row( + <Input + className={cn('h-8', CONTROL_TEXT)} + onChange={e => + onChange( + e.target.value + .split(',') + .map(s => s.trim()) + .filter(Boolean) + ) + } + placeholder="comma-separated values" + value={Array.isArray(value) ? value.join(', ') : String(value ?? '')} + /> + ) + } + + if (typeof value === 'object' && value !== null) { + return row( + <Textarea + className={cn('min-h-28 resize-y bg-background font-mono', CONTROL_TEXT)} + onChange={e => { + try { + onChange(JSON.parse(e.target.value)) + } catch { + /* keep last valid */ + } + }} + placeholder="Not set" + spellCheck={false} + value={JSON.stringify(value, null, 2)} + />, + true + ) + } + + const isLong = schema.type === 'text' || String(value ?? '').length > 100 + + return row( + isLong ? ( + <Textarea + className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)} + onChange={e => onChange(e.target.value)} + placeholder="Not set" + value={String(value ?? '')} + /> + ) : ( + <Input + className={cn('h-8', CONTROL_TEXT)} + onChange={e => onChange(e.target.value)} + placeholder="Not set" + value={String(value ?? '')} + /> + ), + isLong + ) +} + +export function ConfigSettings({ + query, + activeSectionId, + onConfigSaved, + importInputRef +}: SearchProps & { + activeSectionId: string + onConfigSaved?: () => void + importInputRef: React.RefObject<HTMLInputElement | null> +}) { + const [config, setConfig] = useState<HermesConfigRecord | null>(null) + const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null) + const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null) + const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null) + const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({}) + const saveVersionRef = useRef(0) + const [saveVersion, setSaveVersion] = useState(0) + + useEffect(() => { + let cancelled = false + Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()]) + .then(([c, d, s]) => { + if (cancelled) { + return + } + + setConfig(c) + setDefaults(d) + setSchema(s.fields) + }) + .catch(err => notifyError(err, 'Settings failed to load')) + + return () => void (cancelled = true) + }, []) + + useEffect(() => { + let cancelled = false + + getElevenLabsVoices() + .then(result => { + if (cancelled || !result.available) { + return + } + + setElevenLabsVoiceOptions(result.voices.map(voice => voice.voice_id)) + setElevenLabsVoiceLabels(Object.fromEntries(result.voices.map(voice => [voice.voice_id, voice.label]))) + }) + .catch(() => { + if (!cancelled) { + setElevenLabsVoiceOptions(null) + setElevenLabsVoiceLabels({}) + } + }) + + return () => void (cancelled = true) + }, []) + + useEffect(() => { + if (!config || saveVersion === 0) { + return + } + + const v = saveVersion + + const t = window.setTimeout(() => { + void (async () => { + try { + await saveHermesConfig(config) + + if (saveVersionRef.current === v) { + onConfigSaved?.() + } + } catch (err) { + if (saveVersionRef.current === v) { + notifyError(err, 'Autosave failed') + } + } + })() + }, 550) + + return () => window.clearTimeout(t) + }, [config, onConfigSaved, saveVersion]) + + const updateConfig = (next: HermesConfigRecord) => { + saveVersionRef.current += 1 + setConfig(next) + setSaveVersion(saveVersionRef.current) + } + + const sectionFields = useMemo(() => { + if (!schema) { + return new Map<string, [string, ConfigFieldSchema][]>() + } + + return new Map( + SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))]) + ) + }, [schema]) + + const matched = useMemo(() => { + const q = query.trim().toLowerCase() + + if (!schema || !q) { + return [] + } + + const seen = new Set<string>() + + return SECTIONS.flatMap(s => + s.keys.flatMap(k => { + if (seen.has(k) || !schema[k]) { + return [] + } + + seen.add(k) + const label = prettyName(k.split('.').pop() ?? k) + const item = schema[k] + + const hit = + k.toLowerCase().includes(q) || + label.toLowerCase().includes(q) || + includesQuery(item.category, q) || + includesQuery(item.description, q) + + return hit ? [[k, item] as [string, ConfigFieldSchema]] : [] + }) + ) + }, [schema, query]) + + const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? []) + + function handleImport(e: ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0] + + if (!file) { + return + } + + const reader = new FileReader() + + reader.onload = () => { + try { + updateConfig(JSON.parse(String(reader.result))) + notify({ kind: 'success', title: 'Config imported', message: 'Saving…' }) + } catch (err) { + notifyError(err, 'Invalid config JSON') + } + } + + reader.readAsText(file) + e.target.value = '' + } + + if (!config || !schema) { + return <LoadingState label="Loading Hermes configuration..." /> + } + + return ( + <SettingsContent> + {query.trim() && ( + <div className="mb-4 text-xs text-muted-foreground"> + {fields.length} result{fields.length === 1 ? '' : 's'} + </div> + )} + {fields.length === 0 ? ( + <EmptyState description="Try a different search term or choose another section." title="No matching settings" /> + ) : ( + <div className="divide-y divide-border/40"> + {fields.map(([key, field]) => ( + <ConfigField + enumOptions={ + key === 'tts.elevenlabs.voice_id' + ? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined) + : enumOptionsFor(key, getNested(config, key), config) + } + key={key} + onChange={value => updateConfig(setNested(config, key, value))} + optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined} + schema={field} + schemaKey={key} + value={getNested(config, key)} + /> + ))} + </div> + )} + <input + accept=".json,application/json" + className="hidden" + onChange={handleImport} + ref={importInputRef} + type="file" + /> + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts new file mode 100644 index 000000000..8919661f8 --- /dev/null +++ b/apps/desktop/src/app/settings/constants.ts @@ -0,0 +1,321 @@ +import { + Brain, + type IconComponent, + Lock, + MessageCircle, + Mic, + Monitor, + Moon, + Palette, + Sparkles, + Sun, + Wrench +} from '@/lib/icons' +import type { ThemeMode } from '@/themes/context' + +import type { DesktopConfigSection } from './types' + +interface ProviderPrefix { + prefix: string + name: string + priority: number +} + +export const EMPTY_SELECT_VALUE = '__hermes_empty__' +export const CONTROL_TEXT = 'text-[0.8125rem]' + +export const PROVIDER_GROUPS: ProviderPrefix[] = [ + { prefix: 'NOUS_', name: 'Nous Portal', priority: 0 }, + { prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 }, + { prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 }, + { prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 }, + { prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 }, + { prefix: 'GOOGLE_', name: 'Gemini', priority: 4 }, + { prefix: 'GEMINI_', name: 'Gemini', priority: 4 }, + { prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 }, + { prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 }, + { prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 }, + { prefix: 'HF_', name: 'Hugging Face', priority: 6 }, + { prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 }, + { prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 }, + { prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 }, + { prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 }, + { prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 }, + { prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 }, + { prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 } +] + +export const BUILTIN_PERSONALITIES = [ + 'helpful', + 'concise', + 'technical', + 'creative', + 'teacher', + 'kawaii', + 'catgirl', + 'pirate', + 'shakespeare', + 'surfer', + 'noir', + 'uwu', + 'philosopher', + 'hype' +] + +// Schema-side select overrides for desktop-relevant enum fields whose +// backend schema only declares a string type. +export const ENUM_OPTIONS: Record<string, string[]> = { + 'agent.image_input_mode': ['auto', 'native', 'text'], + 'approvals.mode': ['manual', 'smart', 'off'], + 'code_execution.mode': ['project', 'strict'], + 'context.engine': ['compressor', 'default', 'custom'], + 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'], + 'memory.provider': ['', 'builtin', 'honcho'], + 'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'], + 'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'], + 'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'] +} + +export const FIELD_LABELS: Record<string, string> = { + model: 'Default Model', + model_context_length: 'Context Window', + fallback_providers: 'Fallback Models', + toolsets: 'Enabled Toolsets', + timezone: 'Timezone', + 'display.personality': 'Personality', + 'display.show_reasoning': 'Reasoning Blocks', + 'agent.max_turns': 'Max Agent Steps', + 'agent.image_input_mode': 'Image Attachments', + 'terminal.cwd': 'Working Directory', + 'terminal.backend': 'Execution Backend', + 'terminal.timeout': 'Command Timeout', + 'terminal.persistent_shell': 'Persistent Shell', + 'terminal.env_passthrough': 'Environment Passthrough', + file_read_max_chars: 'File Read Limit', + 'tool_output.max_bytes': 'Terminal Output Limit', + 'tool_output.max_lines': 'File Page Limit', + 'tool_output.max_line_length': 'Line Length Limit', + 'code_execution.mode': 'Code Execution Mode', + 'approvals.mode': 'Approval Mode', + 'approvals.timeout': 'Approval Timeout', + 'approvals.mcp_reload_confirm': 'Confirm MCP Reloads', + command_allowlist: 'Command Allowlist', + 'security.redact_secrets': 'Redact Secrets', + 'security.allow_private_urls': 'Allow Private URLs', + 'browser.allow_private_urls': 'Browser Private URLs', + 'browser.auto_local_for_private_urls': 'Local Browser For Private URLs', + 'checkpoints.enabled': 'File Checkpoints', + 'checkpoints.max_snapshots': 'Checkpoint Limit', + 'voice.record_key': 'Voice Shortcut', + 'voice.max_recording_seconds': 'Max Recording Length', + 'voice.auto_tts': 'Read Responses Aloud', + 'stt.enabled': 'Speech To Text', + 'stt.provider': 'Speech-To-Text Provider', + 'stt.local.model': 'Local Transcription Model', + 'stt.local.language': 'Transcription Language', + 'stt.elevenlabs.model_id': 'ElevenLabs STT Model', + 'stt.elevenlabs.language_code': 'ElevenLabs Language', + 'stt.elevenlabs.tag_audio_events': 'Tag Audio Events', + 'stt.elevenlabs.diarize': 'Speaker Diarization', + 'tts.provider': 'Text-To-Speech Provider', + 'tts.edge.voice': 'Edge Voice', + 'tts.openai.model': 'OpenAI TTS Model', + 'tts.openai.voice': 'OpenAI Voice', + 'tts.elevenlabs.voice_id': 'ElevenLabs Voice', + 'tts.elevenlabs.model_id': 'ElevenLabs Model', + 'memory.memory_enabled': 'Persistent Memory', + 'memory.user_profile_enabled': 'User Profile', + 'memory.memory_char_limit': 'Memory Budget', + 'memory.user_char_limit': 'Profile Budget', + 'memory.provider': 'Memory Provider', + 'context.engine': 'Context Engine', + 'compression.enabled': 'Auto-Compression', + 'compression.threshold': 'Compression Threshold', + 'compression.target_ratio': 'Compression Target', + 'compression.protect_last_n': 'Protected Recent Messages', + 'agent.api_max_retries': 'API Retries', + 'agent.service_tier': 'Service Tier', + 'agent.tool_use_enforcement': 'Tool-Use Enforcement', + 'delegation.model': 'Subagent Model', + 'delegation.provider': 'Subagent Provider', + 'delegation.max_iterations': 'Subagent Turn Limit', + 'delegation.max_concurrent_children': 'Parallel Subagents', + 'delegation.child_timeout_seconds': 'Subagent Timeout', + 'delegation.reasoning_effort': 'Subagent Reasoning Effort', + 'auxiliary.vision.provider': 'Vision Provider', + 'auxiliary.vision.model': 'Vision Model', + 'auxiliary.compression.provider': 'Compression Provider', + 'auxiliary.compression.model': 'Compression Model', + 'auxiliary.title_generation.provider': 'Title Provider', + 'auxiliary.title_generation.model': 'Title Model' +} + +export const FIELD_DESCRIPTIONS: Record<string, string> = { + model: 'Used for new chats unless you pick a different model in the composer.', + model_context_length: "Leave at 0 to use the selected model's detected context window.", + fallback_providers: 'Backup provider:model entries to try if the default model fails.', + 'display.personality': 'Default assistant style for new sessions.', + timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.', + 'display.show_reasoning': 'Show reasoning sections when the backend provides them.', + 'agent.image_input_mode': 'Controls how image attachments are sent to the model.', + 'terminal.cwd': 'Default project folder for tool and terminal work.', + 'code_execution.mode': 'How strictly code execution is scoped to the current project.', + 'terminal.persistent_shell': 'Keep shell state between commands when the backend supports it.', + 'terminal.env_passthrough': 'Environment variables to pass into tool execution.', + file_read_max_chars: 'Maximum characters Hermes can read from one file request.', + 'approvals.mode': 'How Hermes handles commands that need explicit approval.', + 'approvals.timeout': 'How long approval prompts wait before timing out.', + 'security.redact_secrets': 'Hide detected secrets from model-visible content when possible.', + 'checkpoints.enabled': 'Create rollback snapshots before file edits.', + 'memory.memory_enabled': 'Save durable memories that can help future sessions.', + 'memory.user_profile_enabled': 'Maintain a compact profile of user preferences.', + 'context.engine': 'Strategy for managing long conversations near the context limit.', + 'compression.enabled': 'Summarize older context when conversations get large.', + 'voice.auto_tts': 'Automatically speak assistant responses.', + 'stt.enabled': 'Enable local or provider-backed speech transcription.', + 'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.', + 'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.' +} + +// Curated desktop config surface: only fields a user might tune from the app. +export const SECTIONS: DesktopConfigSection[] = [ + { + id: 'model', + label: 'Model', + icon: Sparkles, + keys: ['model', 'model_context_length', 'fallback_providers'] + }, + { + id: 'chat', + label: 'Chat', + icon: MessageCircle, + keys: ['display.personality', 'timezone', 'display.show_reasoning', 'agent.image_input_mode'] + }, + { + id: 'appearance', + label: 'Appearance', + icon: Palette, + keys: [] + }, + { + id: 'workspace', + label: 'Workspace', + icon: Monitor, + keys: [ + 'terminal.cwd', + 'code_execution.mode', + 'terminal.persistent_shell', + 'terminal.env_passthrough', + 'file_read_max_chars' + ] + }, + { + id: 'safety', + label: 'Safety', + icon: Lock, + keys: [ + 'approvals.mode', + 'approvals.timeout', + 'approvals.mcp_reload_confirm', + 'command_allowlist', + 'security.redact_secrets', + 'security.allow_private_urls', + 'browser.allow_private_urls', + 'browser.auto_local_for_private_urls', + 'checkpoints.enabled' + ] + }, + { + id: 'memory', + label: 'Memory & Context', + icon: Brain, + keys: [ + 'memory.memory_enabled', + 'memory.user_profile_enabled', + 'memory.memory_char_limit', + 'memory.user_char_limit', + 'memory.provider', + 'context.engine', + 'compression.enabled', + 'compression.threshold', + 'compression.target_ratio', + 'compression.protect_last_n' + ] + }, + { + id: 'voice', + label: 'Voice', + icon: Mic, + keys: [ + 'tts.provider', + 'stt.enabled', + 'stt.provider', + 'voice.auto_tts', + 'tts.edge.voice', + 'tts.openai.model', + 'tts.openai.voice', + 'tts.elevenlabs.voice_id', + 'tts.elevenlabs.model_id', + 'stt.local.model', + 'stt.local.language', + 'stt.elevenlabs.model_id', + 'stt.elevenlabs.language_code', + 'stt.elevenlabs.tag_audio_events', + 'stt.elevenlabs.diarize', + 'voice.record_key', + 'voice.max_recording_seconds' + ] + }, + { + id: 'advanced', + label: 'Advanced', + icon: Wrench, + keys: [ + 'toolsets', + 'terminal.backend', + 'terminal.timeout', + 'tool_output.max_bytes', + 'tool_output.max_lines', + 'tool_output.max_line_length', + 'checkpoints.max_snapshots', + 'agent.max_turns', + 'agent.api_max_retries', + 'agent.service_tier', + 'agent.tool_use_enforcement', + 'delegation.model', + 'delegation.provider', + 'delegation.max_iterations', + 'delegation.max_concurrent_children', + 'delegation.child_timeout_seconds', + 'delegation.reasoning_effort', + 'auxiliary.vision.provider', + 'auxiliary.vision.model', + 'auxiliary.compression.provider', + 'auxiliary.compression.model', + 'auxiliary.title_generation.provider', + 'auxiliary.title_generation.model' + ] + } +] + +export interface ModeOption { + id: ThemeMode + label: string + description: string + icon: IconComponent +} + +export const MODE_OPTIONS: ModeOption[] = [ + { id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun }, + { id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon }, + { id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor } +] + +export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = { + about: 'About Hermes Desktop', + config: 'Search settings...', + gateway: 'Gateway connection...', + keys: 'Search API keys...', + mcp: 'Search MCP servers...', + tools: 'Search skills and tools...' +} diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx new file mode 100644 index 000000000..81649a162 --- /dev/null +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' + +import { CONTROL_TEXT } from './constants' +import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' + +type Mode = 'local' | 'remote' + +interface GatewaySettingsState { + envOverride: boolean + mode: Mode + remoteTokenPreview: string | null + remoteTokenSet: boolean + remoteUrl: string +} + +const EMPTY_STATE: GatewaySettingsState = { + envOverride: false, + mode: 'local', + remoteTokenPreview: null, + remoteTokenSet: false, + remoteUrl: '' +} + +function ModeCard({ + active, + description, + disabled, + icon: Icon, + onSelect, + title +}: { + active: boolean + description: string + disabled?: boolean + icon: typeof Monitor + onSelect: () => void + title: string +}) { + return ( + <button + className={cn( + 'rounded-xl border p-3 text-left transition', + active + ? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)' + : 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)', + disabled && 'cursor-not-allowed opacity-50' + )} + disabled={disabled} + onClick={onSelect} + type="button" + > + <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> + <Icon className="size-4 text-muted-foreground" /> + <span>{title}</span> + {active ? <Check className="ml-auto size-4 text-primary" /> : null} + </div> + <p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {description} + </p> + </button> + ) +} + +export function GatewaySettings() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE) + const [remoteToken, setRemoteToken] = useState('') + const [lastTest, setLastTest] = useState<null | string>(null) + + useEffect(() => { + let cancelled = false + const desktop = window.hermesDesktop + + if (!desktop?.getConnectionConfig) { + setLoading(false) + + return () => void (cancelled = true) + } + + desktop + .getConnectionConfig() + .then(config => { + if (cancelled) { + return + } + + setState(config) + }) + .catch(err => notifyError(err, 'Gateway settings failed to load')) + .finally(() => { + if (!cancelled) { + setLoading(false) + } + }) + + return () => void (cancelled = true) + }, []) + + const canUseRemote = useMemo( + () => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet), + [remoteToken, state.remoteTokenSet, state.remoteUrl] + ) + + const payload = () => ({ + mode: state.mode, + remoteToken: remoteToken.trim() || undefined, + remoteUrl: state.remoteUrl.trim() + }) + + const save = async (apply: boolean) => { + if (state.mode === 'remote' && !canUseRemote) { + notify({ + kind: 'warning', + title: 'Remote gateway incomplete', + message: 'Enter a remote URL and session token before switching to remote.' + }) + + return + } + + setSaving(true) + + try { + const next = apply + ? await window.hermesDesktop.applyConnectionConfig(payload()) + : await window.hermesDesktop.saveConnectionConfig(payload()) + + setState(next) + setRemoteToken('') + notify({ + kind: 'success', + title: apply ? 'Gateway connection restarting' : 'Gateway settings saved', + message: apply ? 'Hermes Desktop will reconnect using the saved settings.' : 'Saved for the next restart.' + }) + } catch (err) { + notifyError(err, apply ? 'Could not apply gateway settings' : 'Could not save gateway settings') + } finally { + setSaving(false) + } + } + + const testRemote = async () => { + if (!canUseRemote) { + notify({ + kind: 'warning', + title: 'Remote gateway incomplete', + message: 'Enter a remote URL and session token before testing.' + }) + + return + } + + setTesting(true) + setLastTest(null) + + try { + const result = await window.hermesDesktop.testConnectionConfig({ + mode: 'remote', + remoteToken: remoteToken.trim() || undefined, + remoteUrl: state.remoteUrl.trim() + }) + + const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}` + setLastTest(message) + notify({ kind: 'success', title: 'Remote gateway reachable', message }) + } catch (err) { + notifyError(err, 'Remote gateway test failed') + } finally { + setTesting(false) + } + } + + if (loading) { + return <LoadingState label="Loading gateway settings..." /> + } + + if (!window.hermesDesktop?.getConnectionConfig) { + return ( + <EmptyState + description="The desktop IPC bridge does not expose gateway settings." + title="Gateway settings unavailable" + /> + ) + } + + return ( + <SettingsContent> + <div className="mb-5"> + <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> + <Globe className="size-4 text-muted-foreground" /> + Gateway Connection + {state.envOverride ? <Pill tone="primary">env override</Pill> : null} + </div> + <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control + an already-running Hermes backend on another machine or behind a trusted proxy. + </p> + </div> + + {state.envOverride ? ( + <div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive"> + <AlertCircle className="mt-0.5 size-4 shrink-0" /> + <div> + <div className="font-medium">Environment variables are controlling this desktop session.</div> + <div className="mt-1 leading-5"> + Unset <code>HERMES_DESKTOP_REMOTE_URL</code> and <code>HERMES_DESKTOP_REMOTE_TOKEN</code> to use the saved + setting below. + </div> + </div> + </div> + ) : null} + + <div className="grid gap-3 sm:grid-cols-2"> + <ModeCard + active={state.mode === 'local'} + description="Start a private Hermes backend on localhost. This is the default and works offline." + disabled={state.envOverride} + icon={Monitor} + onSelect={() => setState(current => ({ ...current, mode: 'local' }))} + title="Local gateway" + /> + <ModeCard + active={state.mode === 'remote'} + description="Connect this desktop shell to a remote Hermes backend using its session token." + disabled={state.envOverride} + icon={Globe} + onSelect={() => setState(current => ({ ...current, mode: 'remote' }))} + title="Remote gateway" + /> + </div> + + <div className="mt-5 divide-y divide-border/40"> + <ListRow + action={ + <Input + className={cn('h-8', CONTROL_TEXT)} + disabled={state.envOverride} + onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))} + placeholder="https://gateway.example.com/hermes" + value={state.remoteUrl} + /> + } + description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes." + title="Remote URL" + /> + <ListRow + action={ + <Input + autoComplete="off" + className={cn('h-8 font-mono', CONTROL_TEXT)} + disabled={state.envOverride} + onChange={event => setRemoteToken(event.target.value)} + placeholder={ + state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token' + } + type="password" + value={remoteToken} + /> + } + description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token." + title="Session token" + /> + </div> + + {lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null} + + <div className="mt-6 flex flex-wrap justify-end gap-3"> + <Button + disabled={state.envOverride || testing || !canUseRemote} + onClick={() => void testRemote()} + variant="outline" + > + {testing ? <Loader2 className="size-4 animate-spin" /> : null} + Test remote + </Button> + <Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline"> + Save for next restart + </Button> + <Button disabled={state.envOverride || saving} onClick={() => void save(true)}> + {saving ? <Loader2 className="size-4 animate-spin" /> : null} + Save and reconnect + </Button> + </div> + + <div className="mt-6 divide-y divide-border/40"> + <ListRow + action={ + <Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline"> + <FileText className="size-4" /> + Open logs + </Button> + } + description="Reveal desktop.log in your file manager — useful when the gateway fails to start." + title="Diagnostics" + /> + </div> + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts new file mode 100644 index 000000000..87ff47bb2 --- /dev/null +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import type { HermesConfigRecord } from '@/types/hermes' + +import { getNested, setNested } from './helpers' + +describe('settings helpers', () => { + it('reads and writes nested config paths', () => { + const config: HermesConfigRecord = { display: { theme: 'mono' } } + const next = setNested(config, 'display.theme', 'slate') + + expect(getNested(next, 'display.theme')).toBe('slate') + expect(getNested(config, 'display.theme')).toBe('mono') + }) + + it('rejects prototype-polluting config paths', () => { + const config: HermesConfigRecord = {} + + expect(() => setNested(config, '__proto__.polluted', true)).toThrow('Unsafe config path') + expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path') + expect(({} as Record<string, unknown>).polluted).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts new file mode 100644 index 000000000..f27db8478 --- /dev/null +++ b/apps/desktop/src/app/settings/helpers.ts @@ -0,0 +1,123 @@ +import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes' + +import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants' + +export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v)) + +export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q) + +export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + +export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : []) + +export const withoutKey = <T>(record: Record<string, T>, key: string) => { + const next = { ...record } + delete next[key] + + return next +} + +export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`) + +export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other' + +export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99 + +const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype']) + +function isSafePart(part: string): boolean { + return part.length > 0 && !POLLUTING_PATH_PARTS.has(part) +} + +function configPathParts(path: string): string[] { + const parts = path.split('.') + + if (!parts.every(isSafePart)) { + throw new Error(`Unsafe config path: ${path}`) + } + + return parts +} + +function safeSet(target: Record<string, unknown>, key: string, value: unknown): void { + if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) { + throw new Error(`Unsafe config key: ${key}`) + } + + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true + }) +} + +export function getNested(obj: HermesConfigRecord, path: string): unknown { + let cur: unknown = obj + + for (const part of configPathParts(path)) { + if (cur == null || typeof cur !== 'object') { + return undefined + } + + if (!Object.prototype.hasOwnProperty.call(cur, part)) { + return undefined + } + + cur = (cur as Record<string, unknown>)[part] + } + + return cur +} + +export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord { + const clone = structuredClone(obj) + const parts = configPathParts(path) + let cur: Record<string, unknown> = clone + + for (let i = 0; i < parts.length - 1; i += 1) { + const part = parts[i] + + if (!isSafePart(part)) { + throw new Error(`Unsafe config path part: ${part}`) + } + + const existing = Object.prototype.hasOwnProperty.call(cur, part) ? cur[part] : undefined + + if (existing == null || typeof existing !== 'object') { + safeSet(cur, part, {}) + } + + cur = cur[part] as Record<string, unknown> + } + + safeSet(cur, parts[parts.length - 1], value) + + return clone +} + +function personalityOptions(config: HermesConfigRecord): string[] { + const custom = getNested(config, 'agent.personalities') + + const customNames = + custom && typeof custom === 'object' && !Array.isArray(custom) ? Object.keys(custom as Record<string, unknown>) : [] + + return [...new Set(['', ...BUILTIN_PERSONALITIES, ...customNames])] +} + +export function enumOptionsFor( + key: string, + value: unknown, + config: HermesConfigRecord, + dynamicOptions?: string[] +): string[] | undefined { + const opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key]) + + if (!opts) { + return undefined + } + + const current = asText(value) + + return current && !opts.includes(current) ? [...opts, current] : opts +} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx new file mode 100644 index 000000000..a7c2d67a6 --- /dev/null +++ b/apps/desktop/src/app/settings/index.tsx @@ -0,0 +1,212 @@ +import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react' +import { useEffect, useRef, useState } from 'react' + +import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' +import { triggerHaptic } from '@/lib/haptics' +import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons' +import { notifyError } from '@/store/notifications' + +import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { OverlayIconButton } from '../overlays/overlay-chrome' +import { OverlaySearchInput } from '../overlays/overlay-search-input' +import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' +import { OverlayView } from '../overlays/overlay-view' + +import { AboutSettings } from './about-settings' +import { AppearanceSettings } from './appearance-settings' +import { ConfigSettings } from './config-settings' +import { SEARCH_PLACEHOLDER, SECTIONS } from './constants' +import { GatewaySettings } from './gateway-settings' +import { KeysSettings } from './keys-settings' +import { McpSettings } from './mcp-settings' +import { ToolsSettings } from './tools-settings' +import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types' + +const SETTINGS_VIEWS: readonly SettingsViewId[] = [ + ...SECTIONS.map(s => `config:${s.id}` as SettingsViewId), + 'gateway', + 'keys', + 'mcp', + 'tools', + 'about' +] + +export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) { + const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId) + + const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({ + about: '', + config: '', + gateway: '', + keys: '', + mcp: '', + tools: '' + }) + + const searchInputRef = useRef<HTMLInputElement>(null) + const importInputRef = useRef<HTMLInputElement | null>(null) + + const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey) + const query = queries[queryKey] + const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next })) + + const exportConfig = async () => { + try { + const cfg = await getHermesConfigRecord() + const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'hermes-config.json' + a.click() + URL.revokeObjectURL(url) + triggerHaptic('success') + } catch (err) { + notifyError(err, 'Export failed') + } + } + + const resetConfig = async () => { + if (!window.confirm('Reset all settings to Hermes defaults?')) { + return + } + + try { + await saveHermesConfig(await getHermesConfigDefaults()) + triggerHaptic('success') + onConfigSaved?.() + } catch (err) { + notifyError(err, 'Reset failed') + } + } + + // OverlayView handles Esc; this just adds Cmd/Ctrl+P → focus search. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') { + e.preventDefault() + searchInputRef.current?.focus() + searchInputRef.current?.select() + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + return ( + <OverlayView + closeLabel="Close settings" + headerContent={ + <OverlaySearchInput + containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80" + inputRef={searchInputRef} + onChange={setQuery} + placeholder={SEARCH_PLACEHOLDER[queryKey]} + value={query} + /> + } + onClose={onClose} + > + <OverlaySplitLayout> + <OverlaySidebar> + {SECTIONS.map(s => { + const view = `config:${s.id}` as SettingsViewId + + return ( + <OverlayNavItem + active={activeView === view && !queries.config.trim()} + icon={s.icon} + key={s.id} + label={s.label} + onClick={() => setActiveView(view)} + /> + ) + })} + <div className="my-2 h-px bg-border/30" /> + <OverlayNavItem + active={activeView === 'gateway'} + icon={Globe} + label="Gateway" + onClick={() => setActiveView('gateway')} + /> + <OverlayNavItem + active={activeView === 'keys'} + icon={KeyRound} + label="API Keys" + onClick={() => setActiveView('keys')} + /> + <OverlayNavItem + active={activeView === 'tools'} + icon={Package} + label="Skills & Tools" + onClick={() => setActiveView('tools')} + /> + <OverlayNavItem + active={activeView === 'mcp'} + icon={Wrench} + label="MCP" + onClick={() => setActiveView('mcp')} + /> + <div className="my-2 h-px bg-border/30" /> + <OverlayNavItem + active={activeView === 'about'} + icon={Info} + label="About" + onClick={() => setActiveView('about')} + /> + <div className="mt-auto flex items-center gap-1 pt-2"> + <OverlayIconButton onClick={() => void exportConfig()} title="Export config"> + <IconDownload className="size-3.5" /> + </OverlayIconButton> + <OverlayIconButton + onClick={() => { + triggerHaptic('open') + importInputRef.current?.click() + }} + title="Import config" + > + <IconUpload className="size-3.5" /> + </OverlayIconButton> + <OverlayIconButton + className="hover:text-destructive" + onClick={() => { + triggerHaptic('warning') + void resetConfig() + }} + title="Reset to defaults" + > + <IconRefresh className="size-3.5" /> + </OverlayIconButton> + </div> + </OverlaySidebar> + + <OverlayMain className="p-0"> + {activeView === 'config:appearance' ? ( + <AppearanceSettings /> + ) : activeView === 'about' ? ( + <AboutSettings /> + ) : activeView === 'gateway' ? ( + <GatewaySettings /> + ) : activeView.startsWith('config:') ? ( + <ConfigSettings + activeSectionId={activeView.slice('config:'.length)} + importInputRef={importInputRef} + onConfigSaved={onConfigSaved} + query={queries.config} + /> + ) : activeView === 'keys' ? ( + <KeysSettings query={queries.keys} /> + ) : activeView === 'mcp' ? ( + <McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} /> + ) : ( + <ToolsSettings query={queries.tools} /> + )} + </OverlayMain> + </OverlaySplitLayout> + </OverlayView> + ) +} + +export { SettingsView as SettingsPage } diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx new file mode 100644 index 000000000..242485e5c --- /dev/null +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -0,0 +1,453 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' +import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import type { EnvVarInfo } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' +import { + asText, + includesQuery, + prettyName, + providerGroup, + providerPriority, + redactedValue, + withoutKey +} from './helpers' +import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' +import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types' + +const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced' + +interface EnvActionsProps { + varKey: string + info: EnvVarInfo + saving: string | null + onEdit: () => void + onClear: (key: string) => void + onReveal: (key: string) => void + isRevealed: boolean + showReveal?: boolean +} + +function EnvActions({ + varKey, + info, + saving, + onEdit, + onClear, + onReveal, + isRevealed, + showReveal = true +}: EnvActionsProps) { + return ( + <div className="flex shrink-0 items-center gap-1.5"> + {info.url && ( + <Button asChild size="xs" title="Open provider docs" variant="ghost"> + <a href={info.url} rel="noreferrer" target="_blank"> + Docs + </a> + </Button> + )} + {info.is_set && showReveal && ( + <Button + onClick={() => onReveal(varKey)} + size="icon-xs" + title={isRevealed ? 'Hide value' : 'Reveal value'} + variant="ghost" + > + {isRevealed ? <EyeOff /> : <Eye />} + </Button> + )} + <Button onClick={onEdit} size="xs" variant="outline"> + {info.is_set ? 'Replace' : 'Set'} + </Button> + {info.is_set && ( + <Button + disabled={saving === varKey} + onClick={() => onClear(varKey)} + size="icon-xs" + title="Clear value" + variant="ghost" + > + <Trash2 /> + </Button> + )} + </div> + ) +} + +function EnvVarRow({ + varKey, + info, + edits, + revealed, + saving, + setEdits, + onSave, + onClear, + onReveal, + compact = false +}: EnvRowProps) { + const isEditing = edits[varKey] !== undefined + const isRevealed = revealed[varKey] !== undefined + const value = isRevealed ? revealed[varKey] : info.redacted_value + const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) + + if (compact && !isEditing) { + return ( + <div className="flex items-center justify-between gap-3 py-1.5"> + <div className="min-w-0"> + <div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div> + <div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div> + </div> + <EnvActions + info={info} + isRevealed={isRevealed} + onClear={onClear} + onEdit={startEdit} + onReveal={onReveal} + saving={saving} + showReveal={false} + varKey={varKey} + /> + </div> + ) + } + + return ( + <div className="grid gap-2 rounded-xl bg-background/55 p-3"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="min-w-0"> + <div className="flex flex-wrap items-center gap-2"> + <span className="font-mono text-xs font-medium">{varKey}</span> + <Pill tone={info.is_set ? 'primary' : 'muted'}> + {info.is_set && <Check className="size-3" />} + {info.is_set ? 'Set' : 'Not set'} + </Pill> + </div> + <p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p> + </div> + <EnvActions + info={info} + isRevealed={isRevealed} + onClear={onClear} + onEdit={startEdit} + onReveal={onReveal} + saving={saving} + varKey={varKey} + /> + </div> + + {!isEditing && info.is_set && ( + <div + className={cn( + 'rounded-md px-3 py-2 font-mono text-xs', + isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground' + )} + > + {value || '---'} + </div> + )} + + {isEditing && ( + <div className="flex flex-wrap items-center gap-2"> + <Input + autoFocus + className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)} + onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))} + placeholder={info.is_set ? 'Replace current value' : 'Enter value'} + type={info.is_password ? 'password' : 'text'} + value={edits[varKey]} + /> + <Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm"> + <Save /> + {saving === varKey ? 'Saving' : 'Save'} + </Button> + <Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline"> + <Codicon name="close" /> + Cancel + </Button> + </div> + )} + </div> + ) +} + +function EnvProviderGroup({ + group, + rowProps +}: { + group: ProviderGroup + rowProps: Omit<EnvRowProps, 'varKey' | 'info'> +}) { + const [expanded, setExpanded] = useState(false) + const setCount = group.entries.filter(([, info]) => info.is_set).length + + return ( + <div className="overflow-hidden rounded-xl bg-background/60"> + <button + className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50" + onClick={() => setExpanded(e => !e)} + type="button" + > + <span className="flex min-w-0 items-center gap-2"> + <Zap className="size-4 shrink-0 text-muted-foreground" /> + <span className="truncate text-sm font-medium"> + {group.name === 'Other' ? 'Other providers' : group.name} + </span> + {setCount > 0 && <Pill tone="primary">{setCount} set</Pill>} + </span> + <span className="text-xs text-muted-foreground">{group.entries.length} keys</span> + </button> + {expanded && ( + <div className="grid gap-2 bg-muted/20 p-3"> + {group.entries.map(([key, info]) => ( + <EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} /> + ))} + </div> + )} + </div> + ) +} + +export function KeysSettings({ query }: SearchProps) { + const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null) + const [edits, setEdits] = useState<Record<string, string>>({}) + const [revealed, setRevealed] = useState<Record<string, string>>({}) + const [saving, setSaving] = useState<string | null>(null) + + const [showAdvanced, setShowAdvanced] = useState<boolean>(() => { + try { + const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY) + + if (stored === null) { + return false + } + + return stored === 'true' + } catch { + return false + } + }) + + useEffect(() => { + try { + window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false') + } catch { + // Ignore persistence failures and keep in-memory preference. + } + }, [showAdvanced]) + + useEffect(() => { + let cancelled = false + + void (async () => { + try { + const next = await getEnvVars() + + if (!cancelled) { + setVars(next) + } + } catch (err) { + notifyError(err, 'API keys failed to load') + } + })() + + return () => void (cancelled = true) + }, []) + + const filterEnv = useCallback( + (info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => { + if (asText(info.category) !== cat) { + return false + } + + if (!showAdvanced && Boolean(info.advanced)) { + return false + } + + if (!q) { + return true + } + + return ( + key.toLowerCase().includes(q) || + includesQuery(info.description, q) || + Boolean(extra && extra.toLowerCase().includes(q)) + ) + }, + [showAdvanced] + ) + + const providerGroups = useMemo<ProviderGroup[]>(() => { + if (!vars) { + return [] + } + + const q = query.trim().toLowerCase() + + const entries = Object.entries(vars).filter(([key, info]) => + filterEnv(info, key, q, 'provider', providerGroup(key)) + ) + + const groups = new Map<string, [string, EnvVarInfo][]>() + + for (const entry of entries) { + const name = providerGroup(entry[0]) + groups.set(name, [...(groups.get(name) ?? []), entry]) + } + + return Array.from(groups, ([name, entries]) => ({ + name, + priority: providerPriority(name), + entries: entries.sort(([a], [b]) => a.localeCompare(b)), + hasAnySet: entries.some(([, info]) => info.is_set) + })).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) + }, [filterEnv, query, vars]) + + const otherGroups = useMemo(() => { + if (!vars) { + return [] + } + + const q = query.trim().toLowerCase() + + const labels: Record<string, string> = { + tool: 'Tools', + messaging: 'Messaging', + setting: 'Settings' + } + + return ['tool', 'messaging', 'setting'].flatMap(cat => { + const entries = Object.entries(vars) + .filter(([key, info]) => filterEnv(info, key, q, cat)) + .sort(([a], [b]) => a.localeCompare(b)) + + return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }] + }) + }, [filterEnv, query, vars]) + + function patchVar(key: string, patch: EnvPatch) { + setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c)) + } + + function clearLocalState(key: string) { + setEdits(c => withoutKey(c, key)) + setRevealed(c => withoutKey(c, key)) + } + + async function handleSave(key: string) { + const value = edits[key] + + if (!value) { + return + } + + setSaving(key) + + try { + await setEnvVar(key, value) + patchVar(key, { is_set: true, redacted_value: redactedValue(value) }) + clearLocalState(key) + notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` }) + } catch (err) { + notifyError(err, `Failed to save ${key}`) + } finally { + setSaving(null) + } + } + + async function handleClear(key: string) { + if (!window.confirm(`Remove ${key} from .env?`)) { + return + } + + setSaving(key) + + try { + await deleteEnvVar(key) + patchVar(key, { is_set: false, redacted_value: null }) + clearLocalState(key) + notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` }) + } catch (err) { + notifyError(err, `Failed to remove ${key}`) + } finally { + setSaving(null) + } + } + + async function handleReveal(key: string) { + if (revealed[key]) { + setRevealed(c => withoutKey(c, key)) + + return + } + + try { + const result = await revealEnvVar(key) + setRevealed(c => ({ ...c, [key]: result.value })) + } catch (err) { + notifyError(err, `Failed to reveal ${key}`) + } + } + + if (!vars) { + return <LoadingState label="Loading API keys and credentials..." /> + } + + const rowProps = { + edits, + revealed, + saving, + setEdits, + onSave: handleSave, + onClear: handleClear, + onReveal: handleReveal + } + + const configuredCount = providerGroups.filter(g => g.hasAnySet).length + + return ( + <SettingsContent> + <div className="mb-4 flex justify-end"> + <Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline"> + {showAdvanced ? 'Hide advanced' : 'Show advanced'} + </Button> + </div> + + <div className="mb-6"> + <SectionHeading + icon={Zap} + meta={`${configuredCount} of ${providerGroups.length} configured`} + title="LLM providers" + /> + <div className="grid gap-2"> + {providerGroups.map(group => ( + <EnvProviderGroup group={group} key={group.name} rowProps={rowProps} /> + ))} + </div> + </div> + + {otherGroups.map(group => ( + <div className="mb-6" key={group.category}> + <SectionHeading + icon={Settings2} + meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`} + title={group.label} + /> + <div className="grid gap-2"> + {group.entries.map(([key, info]) => ( + <EnvVarRow info={info} key={key} varKey={key} {...rowProps} /> + ))} + </div> + </div> + ))} + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/mcp-settings.tsx b/apps/desktop/src/app/settings/mcp-settings.tsx new file mode 100644 index 000000000..794ea3c46 --- /dev/null +++ b/apps/desktop/src/app/settings/mcp-settings.tsx @@ -0,0 +1,266 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useState } from 'react' + +import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes' +import { Package, Wrench } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import { $activeSessionId } from '@/store/session' +import type { HermesConfigRecord } from '@/types/hermes' + +import { includesQuery } from './helpers' +import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' +import type { SearchProps } from './types' + +interface McpSettingsProps extends SearchProps { + gateway?: HermesGateway | null + onConfigSaved?: () => void +} + +type McpServers = Record<string, Record<string, unknown>> + +const EMPTY_SERVER = { + command: '', + args: [], + env: {} +} + +function getServers(config: HermesConfigRecord | null): McpServers { + const raw = config?.mcp_servers + + return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} +} + +const transportLabel = (server: Record<string, unknown>) => + typeof server.transport === 'string' + ? server.transport + : typeof server.url === 'string' + ? 'http' + : typeof server.command === 'string' + ? 'stdio' + : 'custom' + +function serverMatches(name: string, server: Record<string, unknown>, query: string) { + if (!query) { + return true + } + + return includesQuery(name, query) || includesQuery(JSON.stringify(server), query) +} + +export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) { + const activeSessionId = useStore($activeSessionId) + const [config, setConfig] = useState<HermesConfigRecord | null>(null) + const [selected, setSelected] = useState<string | null>(null) + const [name, setName] = useState('') + const [body, setBody] = useState('') + const [saving, setSaving] = useState(false) + const [reloading, setReloading] = useState(false) + + useEffect(() => { + let cancelled = false + + getHermesConfigRecord() + .then(next => { + if (cancelled) { + return + } + + setConfig(next) + const first = Object.keys(getServers(next)).sort()[0] ?? null + setSelected(first) + }) + .catch(err => notifyError(err, 'MCP config failed to load')) + + return () => void (cancelled = true) + }, []) + + const servers = useMemo(() => getServers(config), [config]) + const names = useMemo(() => Object.keys(servers).sort(), [servers]) + + const filtered = useMemo( + () => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())), + [names, query, servers] + ) + + useEffect(() => { + const server = selected ? servers[selected] : null + + setName(selected ?? '') + setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2)) + }, [selected, servers]) + + if (!config) { + return <LoadingState label="Loading MCP servers..." /> + } + + const saveServer = async () => { + const nextName = name.trim() + + if (!nextName) { + notify({ kind: 'error', title: 'Name required', message: 'Give this MCP server a config key.' }) + + return + } + + let parsed: Record<string, unknown> + + try { + const raw = JSON.parse(body) + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Server config must be a JSON object') + } + + parsed = raw as Record<string, unknown> + } catch (err) { + notifyError(err, 'Invalid MCP JSON') + + return + } + + setSaving(true) + + try { + const nextServers = { ...servers } + + if (selected && selected !== nextName) { + delete nextServers[selected] + } + + nextServers[nextName] = parsed + + const nextConfig = { ...config, mcp_servers: nextServers } + await saveHermesConfig(nextConfig) + setConfig(nextConfig) + setSelected(nextName) + onConfigSaved?.() + notify({ kind: 'success', title: 'MCP server saved', message: `${nextName} applies after MCP reload.` }) + } catch (err) { + notifyError(err, 'Save failed') + } finally { + setSaving(false) + } + } + + const removeServer = async (serverName: string) => { + setSaving(true) + + try { + const nextServers = { ...servers } + delete nextServers[serverName] + + const nextConfig = { ...config, mcp_servers: nextServers } + await saveHermesConfig(nextConfig) + setConfig(nextConfig) + setSelected(Object.keys(nextServers).sort()[0] ?? null) + onConfigSaved?.() + } catch (err) { + notifyError(err, 'Remove failed') + } finally { + setSaving(false) + } + } + + const reloadMcp = async () => { + if (!gateway) { + notify({ kind: 'warning', title: 'Gateway unavailable', message: 'Reconnect the gateway before reloading MCP.' }) + + return + } + + setReloading(true) + + try { + await gateway.request('reload.mcp', { + confirm: true, + session_id: activeSessionId ?? undefined + }) + notify({ kind: 'success', title: 'MCP tools reloaded', message: 'New tool schemas apply to fresh turns.' }) + } catch (err) { + notifyError(err, 'MCP reload failed') + } finally { + setReloading(false) + } + } + + return ( + <SettingsContent> + <div className="mb-4 flex items-center justify-between gap-3"> + <SectionHeading icon={Package} meta={`${names.length} configured`} title="MCP servers" /> + <div className="flex items-center gap-2"> + <OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton> + <OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}> + {reloading ? 'Reloading...' : 'Reload MCP'} + </OverlayActionButton> + </div> + </div> + + <div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]"> + <OverlayCard className="min-h-64 overflow-hidden p-2"> + {filtered.length === 0 ? ( + <EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" /> + ) : ( + <div className="grid gap-1"> + {filtered.map(serverName => { + const server = servers[serverName] + const active = selected === serverName + + return ( + <button + className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${ + active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground' + }`} + key={serverName} + onClick={() => setSelected(serverName)} + type="button" + > + <div className="truncate text-sm font-medium">{serverName}</div> + <div className="mt-1 flex items-center gap-1.5"> + <Pill>{transportLabel(server)}</Pill> + {server.disabled === true && <Pill>disabled</Pill>} + </div> + </button> + ) + })} + </div> + )} + </OverlayCard> + + <OverlayCard className="grid gap-3 p-4"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Wrench className="size-4 text-muted-foreground" /> + {selected ? 'Edit server' : 'New server'} + </div> + <label className="grid gap-1.5"> + <span className="text-xs text-muted-foreground">Name</span> + <Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} /> + </label> + <label className="grid gap-1.5"> + <span className="text-xs text-muted-foreground">Server JSON</span> + <Textarea + className="min-h-80 font-mono text-xs" + onChange={event => setBody(event.currentTarget.value)} + spellCheck={false} + value={body} + /> + </label> + <div className="flex items-center justify-between"> + {selected ? ( + <OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger"> + Remove + </OverlayActionButton> + ) : ( + <span /> + )} + <OverlayActionButton disabled={saving} onClick={() => void saveServer()}> + {saving ? 'Saving...' : 'Save server'} + </OverlayActionButton> + </div> + </OverlayCard> + </div> + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx new file mode 100644 index 000000000..d883a5b31 --- /dev/null +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -0,0 +1,121 @@ +import type { ReactNode } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import type { IconComponent } from '@/lib/icons' +import { cn } from '@/lib/utils' + +export function SettingsContent({ children }: { children: ReactNode }) { + return ( + <section className="min-h-0 overflow-hidden"> + <div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20"> + <div className="mx-auto w-full max-w-4xl">{children}</div> + </div> + </section> + ) +} + +export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary'; children: ReactNode }) { + return ( + <span + className={cn( + 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem]', + tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground' + )} + > + {children} + </span> + ) +} + +export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) { + return ( + <div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium"> + <Icon className="size-4 text-muted-foreground" /> + <span>{title}</span> + {meta && <Pill>{meta}</Pill>} + </div> + ) +} + +export function NavLink({ + icon: Icon, + label, + active, + onClick +}: { + icon: IconComponent + label: string + active: boolean + onClick: () => void +}) { + return ( + <Button + className={cn( + 'flex min-h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-[length:var(--conversation-text-font-size)] transition', + active + ? 'bg-(--ui-bg-tertiary) text-foreground' + : 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground' + )} + onClick={onClick} + size="sm" + type="button" + variant="ghost" + > + <Icon className="size-4 shrink-0" /> + <span className="min-w-0 flex-1 truncate">{label}</span> + </Button> + ) +} + +export function ListRow({ + title, + description, + hint, + action, + below, + wide = false +}: { + title: ReactNode + description?: ReactNode + hint?: ReactNode + action?: ReactNode + below?: ReactNode + wide?: boolean +}) { + return ( + <div + className={cn( + 'grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center', + wide && 'sm:grid-cols-1 sm:items-start' + )} + > + <div className="min-w-0"> + <div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div> + {description && ( + <div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {description} + </div> + )} + {hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>} + {below} + </div> + {action && <div className={cn('min-w-0', !wide && 'sm:justify-self-end')}>{action}</div>} + </div> + ) +} + +export function LoadingState({ label }: { label: string }) { + return <PageLoader label={label} /> +} + +export function EmptyState({ title, description }: { title: string; description: string }) { + return ( + <div className="grid min-h-48 place-items-center text-center"> + <div> + <div className="text-sm font-medium">{title}</div> + <div className="mt-1 text-xs text-muted-foreground">{description}</div> + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/settings/tools-settings.test.tsx b/apps/desktop/src/app/settings/tools-settings.test.tsx new file mode 100644 index 000000000..f160a70ab --- /dev/null +++ b/apps/desktop/src/app/settings/tools-settings.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const getSkills = vi.fn() +const getToolsets = vi.fn() +const toggleSkill = vi.fn() +const toggleToolset = vi.fn() + +vi.mock('@/hermes', () => ({ + getSkills: () => getSkills(), + getToolsets: () => getToolsets(), + toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled), + toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled) +})) + +// Notifications hit nanostores/timers we don't care about here. +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +function toolset(overrides: Record<string, unknown> = {}) { + return { + name: 'web', + label: 'Web Search', + description: 'web_search, web_extract', + enabled: true, + available: true, + configured: true, + tools: ['web_search', 'web_extract'], + ...overrides + } +} + +beforeEach(() => { + getSkills.mockResolvedValue([]) + getToolsets.mockResolvedValue([toolset()]) + toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('ToolsSettings toolset toggle', () => { + it('renders a switch for each toolset and toggles it off', async () => { + const { ToolsSettings } = await import('./tools-settings') + render(<ToolsSettings query="" />) + + const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' }) + expect(sw.getAttribute('aria-checked')).toBe('true') + + fireEvent.click(sw) + + await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false)) + }) + + it('keeps the configured pill alongside the switch', async () => { + const { ToolsSettings } = await import('./tools-settings') + render(<ToolsSettings query="" />) + + await screen.findByRole('switch', { name: 'Toggle Web Search toolset' }) + expect(screen.getByText('Configured')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/settings/tools-settings.tsx b/apps/desktop/src/app/settings/tools-settings.tsx new file mode 100644 index 000000000..8e6d63c3a --- /dev/null +++ b/apps/desktop/src/app/settings/tools-settings.tsx @@ -0,0 +1,229 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Switch } from '@/components/ui/switch' +import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes' +import { Brain, Wrench } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import type { SkillInfo, ToolsetInfo } from '@/types/hermes' + +import { asText, includesQuery, prettyName, toolNames } from './helpers' +import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' +import { ToolsetConfigPanel } from './toolset-config-panel' +import type { SearchProps } from './types' + +export function ToolsSettings({ query }: SearchProps) { + const [skills, setSkills] = useState<SkillInfo[] | null>(null) + const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null) + const [savingSkill, setSavingSkill] = useState<string | null>(null) + const [savingToolset, setSavingToolset] = useState<string | null>(null) + const [expandedToolset, setExpandedToolset] = useState<string | null>(null) + + useEffect(() => { + let cancelled = false + Promise.all([getSkills(), getToolsets()]) + .then(([s, t]) => { + if (cancelled) { + return + } + + setSkills(s) + setToolsets(t) + }) + .catch(err => notifyError(err, 'Capabilities failed to load')) + + return () => void (cancelled = true) + }, []) + + const refreshToolsets = useCallback(() => { + getToolsets() + .then(setToolsets) + .catch(err => notifyError(err, 'Toolsets failed to refresh')) + }, []) + + const filteredSkills = useMemo(() => { + if (!skills) { + return [] + } + + const q = query.trim().toLowerCase() + + return skills + .filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q)) + .sort( + (a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name)) + ) + }, [query, skills]) + + const filteredToolsets = useMemo(() => { + if (!toolsets) { + return [] + } + + const q = query.trim().toLowerCase() + + return toolsets + .filter(t => { + if (!q) { + return true + } + + return ( + includesQuery(t.name, q) || + includesQuery(t.label, q) || + includesQuery(t.description, q) || + toolNames(t).some(n => includesQuery(n, q)) + ) + }) + .sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name))) + }, [query, toolsets]) + + const skillGroups = useMemo(() => { + const groups = new Map<string, SkillInfo[]>() + + for (const skill of filteredSkills) { + const cat = asText(skill.category) || 'other' + groups.set(cat, [...(groups.get(cat) ?? []), skill]) + } + + return Array.from(groups).sort(([a], [b]) => a.localeCompare(b)) + }, [filteredSkills]) + + async function handleToggleSkill(skill: SkillInfo, enabled: boolean) { + setSavingSkill(skill.name) + + try { + await toggleSkill(skill.name, enabled) + setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c) + notify({ + kind: 'success', + title: enabled ? 'Skill enabled' : 'Skill disabled', + message: `${skill.name} applies to new sessions.` + }) + } catch (err) { + notifyError(err, `Failed to update ${skill.name}`) + } finally { + setSavingSkill(null) + } + } + + async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) { + setSavingToolset(toolset.name) + + try { + await toggleToolset(toolset.name, enabled) + setToolsets(c => c?.map(t => (t.name === toolset.name ? { ...t, enabled, available: enabled } : t)) ?? c) + notify({ + kind: 'success', + title: enabled ? 'Toolset enabled' : 'Toolset disabled', + message: `${asText(toolset.label || toolset.name)} applies to new sessions.` + }) + } catch (err) { + notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`) + } finally { + setSavingToolset(null) + } + } + + if (!skills || !toolsets) { + return <LoadingState label="Loading skills and toolsets..." /> + } + + return ( + <SettingsContent> + <div className="mb-6"> + <SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" /> + {skillGroups.map(([category, list]) => ( + <div className="mt-4 first:mt-0" key={category}> + <div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + {prettyName(category)} + </div> + <div className="divide-y divide-border/40"> + {list.map(skill => ( + <ListRow + action={ + <Switch + checked={skill.enabled} + disabled={savingSkill === skill.name} + onCheckedChange={c => void handleToggleSkill(skill, c)} + /> + } + description={asText(skill.description)} + key={asText(skill.name)} + title={asText(skill.name)} + /> + ))} + </div> + </div> + ))} + </div> + + <div className="mb-6"> + <SectionHeading + icon={Wrench} + meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`} + title="Toolsets" + /> + <div className="divide-y divide-border/40"> + {filteredToolsets.map(toolset => { + const tools = toolNames(toolset) + const label = asText(toolset.label || toolset.name) + const expanded = expandedToolset === toolset.name + + return ( + <ListRow + action={ + <div className="flex shrink-0 items-center gap-1.5"> + <button + aria-expanded={expanded} + aria-label={`Configure ${label}`} + className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + onClick={() => setExpandedToolset(c => (c === toolset.name ? null : toolset.name))} + type="button" + > + <Pill tone={toolset.configured ? 'primary' : 'muted'}> + {toolset.configured ? 'Configured' : 'Needs keys'} + </Pill> + </button> + <Switch + aria-label={`Toggle ${label} toolset`} + checked={toolset.enabled} + disabled={savingToolset === toolset.name} + onCheckedChange={c => void handleToggleToolset(toolset, c)} + /> + </div> + } + below={ + <> + {tools.length > 0 && ( + <div className="mt-3 flex flex-wrap gap-1"> + {tools.slice(0, 10).map(t => ( + <span + className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground" + key={t} + > + {t} + </span> + ))} + {tools.length > 10 && ( + <span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground"> + +{tools.length - 10} more + </span> + )} + </div> + )} + {expanded && ( + <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} /> + )} + </> + } + description={asText(toolset.description)} + key={asText(toolset.name) || label} + title={label} + /> + ) + })} + </div> + </div> + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx new file mode 100644 index 000000000..89fd5facd --- /dev/null +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -0,0 +1,102 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ToolsetConfig } from '@/types/hermes' + +const getToolsetConfig = vi.fn() +const selectToolsetProvider = vi.fn() +const setEnvVar = vi.fn() +const deleteEnvVar = vi.fn() +const revealEnvVar = vi.fn() + +vi.mock('@/hermes', () => ({ + getToolsetConfig: (name: string) => getToolsetConfig(name), + selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider), + setEnvVar: (key: string, value: string) => setEnvVar(key, value), + deleteEnvVar: (key: string) => deleteEnvVar(key), + revealEnvVar: (key: string) => revealEnvVar(key) +})) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig { + return { + name: 'tts', + has_category: true, + providers: [ + { + name: 'Microsoft Edge TTS', + badge: 'free', + tag: 'No API key needed', + env_vars: [], + post_setup: null, + requires_nous_auth: false + }, + { + name: 'ElevenLabs', + badge: 'paid', + tag: 'Most natural voices', + env_vars: [ + { key: 'ELEVENLABS_API_KEY', prompt: 'ElevenLabs API key', url: 'https://x', default: null, is_set: false } + ], + post_setup: null, + requires_nous_auth: false + } + ], + ...overrides + } +} + +beforeEach(() => { + getToolsetConfig.mockResolvedValue(config()) + selectToolsetProvider.mockResolvedValue({ ok: true, name: 'tts', provider: 'ElevenLabs' }) + setEnvVar.mockResolvedValue({ ok: true }) + deleteEnvVar.mockResolvedValue({ ok: true }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('ToolsetConfigPanel', () => { + it('lists providers from the config endpoint', async () => { + const { ToolsetConfigPanel } = await import('./toolset-config-panel') + render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />) + + expect(await screen.findByText('Microsoft Edge TTS')).toBeTruthy() + expect(screen.getByText('ElevenLabs')).toBeTruthy() + expect(getToolsetConfig).toHaveBeenCalledWith('tts') + }) + + it('selects a provider when clicked', async () => { + const { ToolsetConfigPanel } = await import('./toolset-config-panel') + render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />) + + const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ }) + fireEvent.click(elevenlabs) + + await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs')) + }) + + it('saves an API key for a provider env var', async () => { + const { ToolsetConfigPanel } = await import('./toolset-config-panel') + render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />) + + // Select the keyed provider so its env vars render. + const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ }) + fireEvent.click(elevenlabs) + + // Click "Set" to reveal the input for the unset key. + fireEvent.click(await screen.findByRole('button', { name: 'Set' })) + + const input = await screen.findByPlaceholderText('ElevenLabs API key') + fireEvent.change(input, { target: { value: 'sk-test-123' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('ELEVENLABS_API_KEY', 'sk-test-123')) + }) +}) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx new file mode 100644 index 000000000..27381004f --- /dev/null +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes' +import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes' + +import { Pill } from './primitives' + +interface ToolsetConfigPanelProps { + toolset: string + /** Called after a key is saved/cleared or a provider chosen, so the parent + * can refresh the "Configured / Needs keys" pill. */ + onConfiguredChange?: () => void +} + +function providerConfigured(provider: ToolProvider, envState: Record<string, boolean>): boolean { + if (provider.env_vars.length === 0) { + return true + } + + return provider.env_vars.every(ev => envState[ev.key]) +} + +interface EnvVarFieldProps { + envVar: ToolEnvVar + isSet: boolean + onSaved: (key: string) => void + onCleared: (key: string) => void +} + +function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) { + const [editing, setEditing] = useState(false) + const [value, setValue] = useState('') + const [revealed, setRevealed] = useState<string | null>(null) + const [busy, setBusy] = useState(false) + + async function handleSave() { + if (!value) { + return + } + + setBusy(true) + + try { + await setEnvVar(envVar.key, value) + setEditing(false) + setValue('') + onSaved(envVar.key) + notify({ kind: 'success', title: 'Credential saved', message: `${envVar.key} updated.` }) + } catch (err) { + notifyError(err, `Failed to save ${envVar.key}`) + } finally { + setBusy(false) + } + } + + async function handleClear() { + if (!window.confirm(`Remove ${envVar.key} from .env?`)) { + return + } + + setBusy(true) + + try { + await deleteEnvVar(envVar.key) + setRevealed(null) + onCleared(envVar.key) + notify({ kind: 'success', title: 'Credential removed', message: `${envVar.key} removed.` }) + } catch (err) { + notifyError(err, `Failed to remove ${envVar.key}`) + } finally { + setBusy(false) + } + } + + async function handleReveal() { + if (revealed !== null) { + setRevealed(null) + + return + } + + try { + const result = await revealEnvVar(envVar.key) + setRevealed(result.value) + } catch (err) { + notifyError(err, `Failed to reveal ${envVar.key}`) + } + } + + return ( + <div className="grid gap-2 rounded-lg bg-background/55 p-2.5"> + <div className="flex flex-wrap items-start justify-between gap-2"> + <div className="min-w-0"> + <div className="flex flex-wrap items-center gap-2"> + <span className="font-mono text-xs font-medium">{envVar.key}</span> + <Pill tone={isSet ? 'primary' : 'muted'}> + {isSet && <Check className="size-3" />} + {isSet ? 'Set' : 'Not set'} + </Pill> + </div> + {envVar.prompt && envVar.prompt !== envVar.key && ( + <p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p> + )} + </div> + <div className="flex shrink-0 items-center gap-1.5"> + {envVar.url && ( + <Button asChild size="xs" title="Open provider docs" variant="ghost"> + <a href={envVar.url} rel="noreferrer" target="_blank"> + Docs + <ExternalLink className="size-3" /> + </a> + </Button> + )} + {isSet && ( + <Button onClick={() => void handleReveal()} size="icon-xs" title="Reveal value" variant="ghost"> + {revealed !== null ? <EyeOff /> : <Eye />} + </Button> + )} + <Button onClick={() => setEditing(e => !e)} size="xs" variant="outline"> + {isSet ? 'Replace' : 'Set'} + </Button> + {isSet && ( + <Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" title="Clear value" variant="ghost"> + <Trash2 /> + </Button> + )} + </div> + </div> + + {isSet && revealed !== null && ( + <div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">{revealed || '---'}</div> + )} + + {editing && ( + <div className="flex flex-wrap items-center gap-2"> + <Input + autoFocus + className="min-w-52 flex-1 font-mono" + onChange={e => setValue(e.target.value)} + placeholder={envVar.prompt || envVar.key} + type={envVar.default ? 'text' : 'password'} + value={value} + /> + <Button disabled={busy || !value} onClick={() => void handleSave()} size="sm"> + {busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />} + Save + </Button> + <Button onClick={() => setEditing(false)} size="sm" variant="outline"> + Cancel + </Button> + </div> + )} + </div> + ) +} + +export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) { + const [cfg, setCfg] = useState<ToolsetConfig | null>(null) + const [loading, setLoading] = useState(true) + const [selecting, setSelecting] = useState<string | null>(null) + const [activeProvider, setActiveProvider] = useState<string | null>(null) + // Live per-key set/unset state, seeded from the endpoint then patched locally. + const [envState, setEnvState] = useState<Record<string, boolean>>({}) + + const refresh = useCallback(async () => { + setLoading(true) + + try { + const next = await getToolsetConfig(toolset) + setCfg(next) + const seeded: Record<string, boolean> = {} + + for (const provider of next.providers) { + for (const ev of provider.env_vars) { + seeded[ev.key] = ev.is_set + } + } + + setEnvState(seeded) + } catch (err) { + notifyError(err, 'Tool configuration failed to load') + } finally { + setLoading(false) + } + }, [toolset]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const providers = useMemo(() => cfg?.providers ?? [], [cfg]) + + // Default the expanded provider to the first one that is fully configured, + // else the first provider. + useEffect(() => { + if (activeProvider || providers.length === 0) { + return + } + + const configured = providers.find(p => providerConfigured(p, envState)) + setActiveProvider((configured ?? providers[0]).name) + }, [activeProvider, providers, envState]) + + async function handleSelect(provider: ToolProvider) { + setActiveProvider(provider.name) + setSelecting(provider.name) + + try { + await selectToolsetProvider(toolset, provider.name) + notify({ kind: 'success', title: 'Provider selected', message: `${provider.name} is now active.` }) + onConfiguredChange?.() + } catch (err) { + notifyError(err, `Failed to select ${provider.name}`) + } finally { + setSelecting(null) + } + } + + function patchEnv(key: string, isSet: boolean) { + setEnvState(c => ({ ...c, [key]: isSet })) + onConfiguredChange?.() + } + + const emptyMessage = useMemo(() => { + if (loading || !cfg) { + return null + } + + if (!cfg.has_category) { + return 'This toolset has no provider options — enable it and it works with your current setup.' + } + + if (providers.length === 0) { + return 'No providers are available for this toolset right now.' + } + + return null + }, [cfg, loading, providers.length]) + + if (loading) { + return ( + <div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground"> + <Loader2 className="size-3.5 animate-spin" /> + Loading configuration... + </div> + ) + } + + if (emptyMessage) { + return <p className="px-1 py-3 text-xs text-muted-foreground">{emptyMessage}</p> + } + + return ( + <div className="mt-3 grid gap-2"> + {providers.map(provider => { + const isActive = activeProvider === provider.name + const configured = providerConfigured(provider, envState) + + return ( + <div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}> + <button + aria-pressed={isActive} + className={cn( + 'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50', + isActive && 'bg-accent/40' + )} + onClick={() => void handleSelect(provider)} + type="button" + > + <span className="flex min-w-0 items-center gap-2"> + <span className="truncate text-sm font-medium">{provider.name}</span> + {provider.badge && <Pill>{provider.badge}</Pill>} + {configured && ( + <Pill tone="primary"> + <Check className="size-3" /> + Ready + </Pill> + )} + </span> + {selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />} + </button> + + {isActive && ( + <div className="grid gap-2 bg-muted/20 p-3"> + {provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>} + {provider.requires_nous_auth && ( + <p className="text-[0.72rem] text-muted-foreground"> + Included with a Nous subscription — sign in to Nous Portal to activate. + </p> + )} + {provider.env_vars.length === 0 ? ( + <p className="text-[0.72rem] text-muted-foreground">No API key required.</p> + ) : ( + provider.env_vars.map(ev => ( + <EnvVarField + envVar={ev} + isSet={Boolean(envState[ev.key])} + key={ev.key} + onCleared={key => patchEnv(key, false)} + onSaved={key => patchEnv(key, true)} + /> + )) + )} + {provider.post_setup && ( + <p className="text-[0.72rem] text-muted-foreground"> + This provider needs an extra setup step ({provider.post_setup}). Run it from the CLI with{' '} + <code className="font-mono">hermes tools</code> for now. + </p> + )} + </div> + )} + </div> + ) + })} + </div> + ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts new file mode 100644 index 000000000..ae67c079b --- /dev/null +++ b/apps/desktop/src/app/settings/types.ts @@ -0,0 +1,46 @@ +import type { Dispatch, SetStateAction } from 'react' + +import type { HermesGateway } from '@/hermes' +import type { IconComponent } from '@/lib/icons' +import type { EnvVarInfo } from '@/types/hermes' + +export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}` +export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools' +export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>> + +export interface SettingsPageProps { + gateway?: HermesGateway | null + onClose: () => void + onConfigSaved?: () => void +} + +export interface SearchProps { + query: string +} + +export interface ProviderGroup { + name: string + priority: number + entries: [string, EnvVarInfo][] + hasAnySet: boolean +} + +export interface DesktopConfigSection { + id: string + label: string + icon: IconComponent + keys: string[] +} + +export interface EnvRowProps { + varKey: string + info: EnvVarInfo + edits: Record<string, string> + revealed: Record<string, string> + saving: string | null + setEdits: Dispatch<SetStateAction<Record<string, string>>> + onSave: (key: string) => void + onClear: (key: string) => void + onReveal: (key: string) => void + compact?: boolean +} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx new file mode 100644 index 000000000..98f97a337 --- /dev/null +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -0,0 +1,161 @@ +import { useStore } from '@nanostores/react' +import type { CSSProperties, ReactNode } from 'react' +import { useSyncExternalStore } from 'react' + +import { PaneShell } from '@/components/pane-shell' +import { SidebarProvider } from '@/components/ui/sidebar' +import { + $fileBrowserOpen, + $sidebarOpen, + FILE_BROWSER_DEFAULT_WIDTH, + FILE_BROWSER_PANE_ID, + setSidebarOpen +} from '@/store/layout' +import { $paneWidthOverride } from '@/store/panes' +import { $connection } from '@/store/session' + +import { StatusbarControls, type StatusbarItem } from './statusbar-controls' +import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' +import { TitlebarControls, type TitlebarTool } from './titlebar-controls' + +interface AppShellProps { + children: ReactNode + commandCenterOpen?: boolean + leftStatusbarItems?: readonly StatusbarItem[] + leftTitlebarTools?: readonly TitlebarTool[] + onOpenSettings: () => void + onOpenSearch: () => void + overlays?: ReactNode + statusbarItems?: readonly StatusbarItem[] + titlebarTools?: readonly TitlebarTool[] +} + +// Renderer-side fallback so layout snaps even when the main-process fullscreen event +// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired). +function subscribeWindowSize(cb: () => void) { + window.addEventListener('resize', cb) + window.addEventListener('fullscreenchange', cb) + + return () => { + window.removeEventListener('resize', cb) + window.removeEventListener('fullscreenchange', cb) + } +} + +const viewportIsFullscreen = () => + window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height + +export function AppShell({ + children, + commandCenterOpen = false, + leftStatusbarItems, + leftTitlebarTools, + onOpenSettings, + onOpenSearch, + overlays, + statusbarItems, + titlebarTools +}: AppShellProps) { + const sidebarOpen = useStore($sidebarOpen) + const fileBrowserOpen = useStore($fileBrowserOpen) + const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID)) + const connection = useStore($connection) + const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) + const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen + const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) + // Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero + // on macOS, where window controls sit on the left and are reported via + // windowButtonPosition instead). The right tool cluster has to clear them. + const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0 + const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem' + + const titlebarContentInset = sidebarOpen + ? 0 + : titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2) + + // The static system cluster (haptics, profiles, settings, right-sidebar) is + // hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render + // in a separate cluster anchored further left. + // + // Width math has to include the `gap-x-1` (0.25rem) between buttons: + // N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room + // between the pane-tool cluster and the system cluster so they don't sit + // flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing) + // to keep the formula generic for any pane-tool count. + const SYSTEM_TOOL_COUNT = 4 + const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0 + const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))` + + const fileBrowserWidth = + fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH + + // Where the pane-tool cluster's right edge sits, measured from the inner + // titlebar padding (--titlebar-tools-right). Two anchors: + // - file-browser closed → flush against static cluster's left edge + // - file-browser open → flush against the file-browser pane's left edge + // (= preview pane's right edge) + const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth + + // Used by the drag region to know where the rightmost interactive element + // ends. When pane tools are present, that's `gap + paneCount * controlSize + // + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap + + // paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint + // is enough. + const titlebarToolsWidth = + paneToolCount > 0 + ? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))` + : systemToolsWidth + + return ( + <SidebarProvider + className="h-screen min-h-0 flex-col bg-background" + onOpenChange={setSidebarOpen} + open={sidebarOpen} + style={ + { + // Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar + // pane track via PaneShell's emitted --pane-chat-sidebar-width. + '--sidebar-width': 'var(--pane-chat-sidebar-width)', + '--titlebar-height': `${TITLEBAR_HEIGHT}px`, + '--titlebar-content-inset': `${titlebarContentInset}px`, + '--titlebar-controls-left': `${titlebarControls.left}px`, + '--titlebar-controls-top': `${titlebarControls.top}px`, + '--titlebar-tools-right': titlebarToolsRight, + '--titlebar-tools-width': titlebarToolsWidth, + // Anchor for the pane-tool cluster's right edge in TitlebarControls. + // Sourced from the layout store rather than the PaneShell-emitted + // --pane-*-width vars because the titlebar is a sibling of PaneShell + // and CSS variables resolve at the consumer's scope. + '--shell-preview-toolbar-gap': previewToolbarGap + } as CSSProperties + } + > + <TitlebarControls + commandCenterOpen={commandCenterOpen} + leftTools={leftTitlebarTools} + onOpenSearch={onOpenSearch} + onOpenSettings={onOpenSettings} + tools={titlebarTools} + /> + + <main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none"> + <PaneShell className="min-h-0 flex-1"> + <div + aria-hidden="true" + className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]" + /> + <div + aria-hidden="true" + className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]" + /> + + {children} + </PaneShell> + + <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} /> + </main> + + {overlays} + </SidebarProvider> + ) +} diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx new file mode 100644 index 000000000..fc5a0c218 --- /dev/null +++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx @@ -0,0 +1,145 @@ +import { IconLayoutDashboard } from '@tabler/icons-react' + +import { StatusDot, type StatusTone } from '@/components/status-dot' +import { Button } from '@/components/ui/button' +import { Activity, AlertCircle } from '@/lib/icons' +import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' +import { cn } from '@/lib/utils' +import type { StatusResponse } from '@/types/hermes' + +interface GatewayMenuPanelProps { + gatewayState: string + inferenceStatus: RuntimeReadinessResult | null + logLines: readonly string[] + onOpenSystem: () => void + statusSnapshot: StatusResponse | null +} + +const PLATFORM_TONE: Record<string, StatusTone> = { + connected: 'good', + connecting: 'warn', + retrying: 'warn', + pending_restart: 'warn', + startup_failed: 'bad', + fatal: 'bad' +} + +const prettyState = (state: string) => state.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase()) + +// Strip leading "YYYY-MM-DD HH:MM:SS,mmm " and "[runtime_id] " prefixes from +// log lines so they don't dominate the display. Full text preserved on hover. +const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}[,.\d]*\s+/ +const RUNTIME_BRACKET_RE = /^\[[^\]]+]\s+/ +const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replace(RUNTIME_BRACKET_RE, '') + +export function GatewayMenuPanel({ + gatewayState, + inferenceStatus, + logLines, + onOpenSystem, + statusSnapshot +}: GatewayMenuPanelProps) { + const gatewayOpen = gatewayState === 'open' + const gatewayConnecting = gatewayState === 'connecting' + const inferenceReady = gatewayOpen && inferenceStatus?.ready === true + + const connectionLabel = gatewayOpen + ? 'Connected' + : gatewayConnecting + ? 'Connecting' + : prettyState(gatewayState || 'offline') + + const inferenceLabel = gatewayOpen + ? inferenceStatus?.ready + ? 'Inference ready' + : inferenceStatus + ? 'Inference not ready' + : 'Checking inference' + : 'Disconnected' + + const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r)) + const recentLogs = logLines.slice(-5) + + return ( + <div className="text-sm"> + <div className="flex items-center justify-between gap-2 px-3 py-2.5"> + <div className="flex min-w-0 items-center gap-2"> + {inferenceReady ? ( + <Activity className="size-3.5 text-primary" /> + ) : ( + <AlertCircle className={cn('size-3.5', gatewayOpen ? 'text-amber-600' : 'text-destructive')} /> + )} + <span className="font-medium">Gateway</span> + <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> + <StatusDot tone={inferenceReady ? 'good' : gatewayOpen ? 'warn' : 'bad'} /> + {inferenceLabel} + </span> + </div> + <div className="flex items-center"> + <Button + aria-label="Open system panel" + className="size-7 text-muted-foreground hover:text-foreground" + onClick={onOpenSystem} + size="icon-sm" + title="Open system panel" + variant="ghost" + > + <IconLayoutDashboard /> + </Button> + </div> + </div> + + <div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground"> + <div>Connection: {connectionLabel}</div> + {inferenceStatus?.reason && <div className="mt-1 line-clamp-3">{inferenceStatus.reason}</div>} + </div> + + {recentLogs.length > 0 && ( + <div className="border-t border-border/50 px-3 py-2"> + <SectionLabel>Recent activity</SectionLabel> + <ul className="mt-1.5 space-y-0.5"> + {recentLogs.map((line, index) => ( + <li + className="truncate font-mono text-[0.68rem] text-muted-foreground/85" + key={`${index}:${line}`} + title={line.trim()} + > + {trimLogLine(line) || '\u00A0'} + </li> + ))} + </ul> + <button + className="mt-1.5 text-[0.66rem] font-medium text-muted-foreground hover:text-foreground" + onClick={onOpenSystem} + type="button" + > + View all logs → + </button> + </div> + )} + + {platforms.length > 0 && ( + <div className="border-t border-border/50 px-3 py-2"> + <SectionLabel>Messaging platforms</SectionLabel> + <ul className="mt-1.5 space-y-1"> + {platforms.map(([name, platform]) => ( + <li className="flex items-center justify-between gap-2 text-xs" key={name}> + <span className="truncate capitalize">{name}</span> + <span className="flex items-center gap-1.5 text-[0.66rem] text-muted-foreground"> + <StatusDot tone={PLATFORM_TONE[platform.state] || 'muted'} /> + {prettyState(platform.state)} + </span> + </li> + ))} + </ul> + </div> + )} + </div> + ) +} + +function SectionLabel({ children }: { children: string }) { + return ( + <div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div> + ) +} diff --git a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts new file mode 100644 index 000000000..bf1139b2a --- /dev/null +++ b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { type CommandCenterSection } from '@/app/command-center' +import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes' + +const SECTIONS = ['models', 'sessions', 'system'] as const +const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents']) + +export function useOverlayRouting() { + const location = useLocation() + const navigate = useNavigate() + + const currentView = appViewForPath(location.pathname) + const settingsOpen = currentView === 'settings' + const commandCenterOpen = currentView === 'command-center' + const agentsOpen = currentView === 'agents' + const chatOpen = currentView === 'chat' + const overlayOpen = OVERLAY_VIEWS.has(currentView) + + // Overlay routes (settings/command-center/agents) stash the underlying path + // so closing them returns there instead of bouncing to /. + const returnPathRef = useRef(NEW_CHAT_ROUTE) + + useEffect(() => { + if (!overlayOpen) { + returnPathRef.current = `${location.pathname}${location.search}${location.hash}` + } + }, [location.hash, location.pathname, location.search, overlayOpen]) + + const commandCenterInitialSection = useMemo<CommandCenterSection | undefined>( + () => SECTIONS.find(value => value === new URLSearchParams(location.search).get('section')), + [location.search] + ) + + const openCommandCenterSection = useCallback( + (section: CommandCenterSection) => navigate(`${COMMAND_CENTER_ROUTE}?section=${section}`), + [navigate] + ) + + const closeOverlayToPreviousRoute = useCallback( + () => navigate(returnPathRef.current || NEW_CHAT_ROUTE, { replace: true }), + [navigate] + ) + + const toggleCommandCenter = useCallback(() => { + if (commandCenterOpen) { + closeOverlayToPreviousRoute() + } else { + navigate(COMMAND_CENTER_ROUTE) + } + }, [closeOverlayToPreviousRoute, commandCenterOpen, navigate]) + + const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate]) + + return { + agentsOpen, + chatOpen, + closeOverlayToPreviousRoute, + commandCenterInitialSection, + commandCenterOpen, + currentView, + openAgents, + openCommandCenterSection, + settingsOpen, + toggleCommandCenter + } +} diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts new file mode 100644 index 000000000..f644fe48c --- /dev/null +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react' + +import { getLogs, getStatus } from '@/hermes' +import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' +import type { StatusResponse } from '@/types/hermes' + +const REFRESH_MS = 15_000 +const LOG_TAIL = 12 + +type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> + +export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) { + const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null) + const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([]) + const [inferenceStatus, setInferenceStatus] = useState<RuntimeReadinessResult | null>(null) + + useEffect(() => { + let cancelled = false + + const refresh = async () => { + try { + const [next, logs, inference] = await Promise.all([ + getStatus(), + getLogs({ file: 'gui', lines: LOG_TAIL }).catch(() => ({ lines: [] })), + gatewayState === 'open' + ? evaluateRuntimeReadiness(requestGateway).catch(error => ({ + checksDisagree: false, + ready: false, + reason: error instanceof Error ? error.message : String(error), + source: 'fallback' as const + })) + : Promise.resolve(null) + ]) + + if (cancelled) { + return + } + + setStatusSnapshot(next) + setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean)) + setInferenceStatus(inference) + } catch { + // Keep last snapshot through transient gateway flaps. + } + } + + void refresh() + const timer = window.setInterval(() => void refresh(), REFRESH_MS) + + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [gatewayState, requestGateway]) + + return { gatewayLogLines, inferenceStatus, statusSnapshot } +} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx new file mode 100644 index 000000000..66e7a19fe --- /dev/null +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -0,0 +1,296 @@ +import { useStore } from '@nanostores/react' +import { useMemo } from 'react' + +import type { CommandCenterSection } from '@/app/command-center' +import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' +import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons' +import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' +import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' +import { cn } from '@/lib/utils' +import { $desktopActionTasks } from '@/store/activity' +import { $previewServerRestartStatus } from '@/store/preview' +import { + $busy, + $currentModel, + $currentProvider, + $currentUsage, + $sessionStartedAt, + $turnStartedAt, + $workingSessionIds, + setModelPickerOpen +} from '@/store/session' +import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' +import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates' +import type { StatusResponse } from '@/types/hermes' + +import { CRON_ROUTE } from '../../routes' +import type { StatusbarItem } from '../statusbar-controls' + +interface StatusbarItemsOptions { + agentsOpen: boolean + commandCenterOpen: boolean + extraLeftItems: readonly StatusbarItem[] + extraRightItems: readonly StatusbarItem[] + gatewayLogLines: readonly string[] + gatewayState: string + inferenceStatus: RuntimeReadinessResult | null + openAgents: () => void + openCommandCenterSection: (section: CommandCenterSection) => void + statusSnapshot: StatusResponse | null + toggleCommandCenter: () => void +} + +export function useStatusbarItems({ + agentsOpen, + commandCenterOpen, + extraLeftItems, + extraRightItems, + gatewayLogLines, + gatewayState, + inferenceStatus, + openAgents, + openCommandCenterSection, + statusSnapshot, + toggleCommandCenter +}: StatusbarItemsOptions) { + const busy = useStore($busy) + const currentModel = useStore($currentModel) + const currentProvider = useStore($currentProvider) + const currentUsage = useStore($currentUsage) + const desktopActionTasks = useStore($desktopActionTasks) + const previewServerRestartStatus = useStore($previewServerRestartStatus) + const sessionStartedAt = useStore($sessionStartedAt) + const turnStartedAt = useStore($turnStartedAt) + const workingSessionIds = useStore($workingSessionIds) + const subagentsBySession = useStore($subagentsBySession) + const updateStatus = useStore($updateStatus) + const updateApply = useStore($updateApply) + const desktopVersion = useStore($desktopVersion) + + const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) + const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) + + const gatewayMenuContent = useMemo( + () => ( + <GatewayMenuPanel + gatewayState={gatewayState} + inferenceStatus={inferenceStatus} + logLines={gatewayLogLines} + onOpenSystem={() => openCommandCenterSection('system')} + statusSnapshot={statusSnapshot} + /> + ), + [gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot] + ) + + const { bgFailed, bgRunning, subagentsRunning } = useMemo(() => { + const actions = Object.values(desktopActionTasks) + const running = actions.filter(t => t.status.running).length + const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length + const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0 + const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0 + + const subagentsRunning = Object.values(subagentsBySession).reduce( + (sum, items) => sum + activeSubagentCount(items), + 0 + ) + + return { + bgFailed: failed + previewFailed, + bgRunning: workingSessionIds.length + running + previewRunning, + subagentsRunning + } + }, [desktopActionTasks, previewServerRestartStatus, subagentsBySession, workingSessionIds]) + + const gatewayOpen = gatewayState === 'open' + const gatewayConnecting = gatewayState === 'connecting' + const inferenceReady = gatewayOpen && inferenceStatus?.ready === true + const gatewayDegraded = gatewayOpen || gatewayConnecting + + const gatewayDetail = gatewayOpen + ? inferenceStatus?.ready + ? 'ready' + : inferenceStatus + ? 'needs setup' + : 'checking' + : gatewayConnecting + ? 'connecting' + : 'offline' + + const gatewayClassName = inferenceReady + ? undefined + : gatewayDegraded + ? 'text-amber-600 hover:text-amber-600' + : 'text-destructive hover:text-destructive' + + const versionItem = useMemo<StatusbarItem>(() => { + const appVersion = desktopVersion?.appVersion + const sha = updateStatus?.currentSha?.slice(0, 7) ?? null + const behind = updateStatus?.behind ?? 0 + const applying = updateApply.applying || updateApply.stage === 'restart' + const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown') + const behindHint = !applying && behind > 0 ? ` (+${behind})` : '' + + const label = applying + ? updateApply.stage === 'restart' + ? `${base} · restart` + : `${base} · update` + : `${base}${behindHint}` + + const tooltip = [ + applying ? updateApply.message || 'Update in progress' : null, + !applying && behind > 0 && `${behind} commit${behind === 1 ? '' : 's'} behind ${updateStatus?.branch ?? '…'}`, + appVersion && `Hermes Desktop v${appVersion}`, + sha && `commit ${sha}`, + updateStatus?.branch && `branch ${updateStatus.branch}` + ] + .filter(Boolean) + .join(' · ') + + return { + className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined, + detail: appVersion && sha && !applying ? sha : undefined, + hidden: !appVersion && !sha, + icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />, + id: 'version', + label, + onSelect: () => setUpdateOverlayOpen(true), + title: tooltip || undefined, + variant: 'action' + } + }, [ + desktopVersion?.appVersion, + updateApply.applying, + updateApply.message, + updateApply.stage, + updateStatus?.behind, + updateStatus?.branch, + updateStatus?.currentSha + ]) + + const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>( + () => [ + { + className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`, + icon: <Command className="size-3.5" />, + id: 'command-center', + onSelect: toggleCommandCenter, + title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center', + variant: 'action' + }, + { + className: gatewayClassName, + detail: gatewayDetail, + icon: inferenceReady ? <Activity className="size-3" /> : <AlertCircle className="size-3" />, + id: 'gateway-health', + label: 'Gateway', + menuClassName: 'w-72', + menuContent: gatewayMenuContent, + title: inferenceStatus?.reason || 'Hermes inference gateway status', + variant: 'menu' + }, + { + className: cn( + agentsOpen && 'bg-accent/55 text-foreground', + bgFailed > 0 && 'text-destructive hover:text-destructive' + ), + detail: + subagentsRunning > 0 + ? `${subagentsRunning} subagent${subagentsRunning === 1 ? '' : 's'}` + : bgFailed > 0 + ? `${bgFailed} failed` + : bgRunning > 0 + ? `${bgRunning} running` + : undefined, + icon: + bgFailed > 0 ? ( + <AlertCircle className="size-3" /> + ) : bgRunning > 0 || subagentsRunning > 0 ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Sparkles className="size-3" /> + ), + id: 'agents', + label: 'Agents', + onSelect: openAgents, + title: agentsOpen ? 'Close agents' : 'Open agents', + variant: 'action' + }, + { + icon: <Clock className="size-3" />, + id: 'cron', + label: 'Cron', + title: 'Open cron jobs', + to: CRON_ROUTE, + variant: 'action' + } + ], + [ + agentsOpen, + bgFailed, + bgRunning, + commandCenterOpen, + gatewayMenuContent, + gatewayClassName, + gatewayDetail, + inferenceReady, + inferenceStatus?.reason, + openAgents, + subagentsRunning, + toggleCommandCenter + ] + ) + + const coreRightStatusbarItems = useMemo<readonly StatusbarItem[]>( + () => [ + { + detail: <LiveDuration since={turnStartedAt} />, + hidden: !busy || !turnStartedAt, + icon: <Loader2 className="size-3 animate-spin" />, + id: 'running-timer', + label: 'Running', + title: 'Current turn elapsed', + variant: 'text' + }, + { + detail: contextBar || undefined, + hidden: !contextUsage, + id: 'context-usage', + label: contextUsage, + title: 'Context usage', + variant: 'text' + }, + { + detail: <LiveDuration since={sessionStartedAt} />, + hidden: !sessionStartedAt, + id: 'session-timer', + label: 'Session', + title: 'Runtime session elapsed', + variant: 'text' + }, + { + detail: currentProvider || '', + icon: <Cpu className="size-3" />, + id: 'model-summary', + label: currentModel || 'No model selected', + onSelect: () => setModelPickerOpen(true), + title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker', + variant: 'action' + }, + versionItem + ], + [busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem] + ) + + const leftStatusbarItems = useMemo( + () => [...coreLeftStatusbarItems, ...extraLeftItems], + [coreLeftStatusbarItems, extraLeftItems] + ) + + const statusbarItems = useMemo( + () => [...extraRightItems, ...coreRightStatusbarItems], + [coreRightStatusbarItems, extraRightItems] + ) + + return { leftStatusbarItems, statusbarItems } +} diff --git a/apps/desktop/src/app/shell/sidebar-label.tsx b/apps/desktop/src/app/shell/sidebar-label.tsx new file mode 100644 index 000000000..759bae1d5 --- /dev/null +++ b/apps/desktop/src/app/shell/sidebar-label.tsx @@ -0,0 +1,22 @@ +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +interface SidebarPanelLabelProps extends React.ComponentProps<'span'> { + dotClassName?: string +} + +export function SidebarPanelLabel({ children, className, dotClassName, ...props }: SidebarPanelLabelProps) { + return ( + <span + className={cn( + 'flex min-w-0 items-center gap-2 pl-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--theme-primary)', + className + )} + {...props} + > + <span aria-hidden="true" className={cn('dither inline-block size-2 shrink-0 rounded-[1px]', dotClassName)} /> + <span className="min-w-0 truncate leading-none">{children}</span> + </span> + ) +} diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx new file mode 100644 index 000000000..227d59dc8 --- /dev/null +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -0,0 +1,199 @@ +import type { ComponentProps, ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' + +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' + +export interface StatusbarMenuItem { + id: string + icon?: ReactNode + label: string + className?: string + disabled?: boolean + hidden?: boolean + href?: string + onSelect?: () => void + title?: string + to?: string +} + +export interface StatusbarItem { + id: string + label?: ReactNode + detail?: ReactNode + icon?: ReactNode + className?: string + disabled?: boolean + hidden?: boolean + href?: string + menuClassName?: string + menuContent?: ReactNode + menuItems?: readonly StatusbarMenuItem[] + onSelect?: () => void + title?: string + to?: string + variant?: 'action' | 'link' | 'menu' | 'text' +} + +export type StatusbarItemSide = 'left' | 'right' +export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void + +interface StatusbarControlsProps extends ComponentProps<'footer'> { + leftItems?: readonly StatusbarItem[] + items?: readonly StatusbarItem[] +} + +export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) { + const navigate = useNavigate() + + return ( + <footer + className={cn( + 'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]', + className + )} + {...props} + > + <div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto"> + {leftItems + .filter(item => !item.hidden) + .map(item => ( + <StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} /> + ))} + </div> + <div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto"> + {items + .filter(item => !item.hidden) + .map(item => ( + <StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} /> + ))} + </div> + </footer> + ) +} + +function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) { + const content = ( + <> + {item.icon} + {item.label && <span className="truncate">{item.label}</span>} + {item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>} + </> + ) + + const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined) + + if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + className={cn( + 'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45', + item.className + )} + disabled={item.disabled} + title={title} + type="button" + > + {content} + </button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="start" + className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)} + side="top" + sideOffset={8} + > + {item.menuContent + ? item.menuContent + : (item.menuItems ?? []) + .filter(menuItem => !menuItem.hidden) + .map(menuItem => ( + <DropdownMenuItem + className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)} + disabled={menuItem.disabled} + key={menuItem.id} + onSelect={() => { + if (menuItem.to) { + navigate(menuItem.to) + } + + menuItem.onSelect?.() + }} + > + {menuItem.href ? ( + <a + className="inline-flex w-full items-center gap-2" + href={menuItem.href} + rel="noreferrer" + target="_blank" + title={menuItem.title ?? menuItem.label} + > + {menuItem.icon} + <span className="truncate">{menuItem.label}</span> + </a> + ) : ( + <> + {menuItem.icon} + <span className="truncate">{menuItem.label}</span> + </> + )} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ) + } + + if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) { + return ( + <div + className={cn( + 'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)', + item.className + )} + > + {content} + </div> + ) + } + + if (item.href || item.variant === 'link') { + return ( + <a + className={cn( + 'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45', + item.className + )} + href={item.href} + rel="noreferrer" + target="_blank" + title={title} + > + {content} + </a> + ) + } + + return ( + <button + className={cn( + 'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45', + item.className + )} + disabled={item.disabled} + onClick={() => { + if (item.to) { + navigate(item.to) + } + + item.onSelect?.() + }} + title={title} + type="button" + > + {content} + </button> + ) +} diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx new file mode 100644 index 000000000..1f0a8690e --- /dev/null +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -0,0 +1,260 @@ +import { useStore } from '@nanostores/react' +import type { ComponentProps, ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' + +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { triggerHaptic } from '@/lib/haptics' +import { Volume2, VolumeX } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' +import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout' + +import { PROFILES_ROUTE } from '../routes' + +import { titlebarButtonClass } from './titlebar' + +export interface TitlebarTool { + id: string + label: string + active?: boolean + className?: string + disabled?: boolean + hidden?: boolean + href?: string + icon: ReactNode + onSelect?: () => void + title?: string + to?: string +} + +export type TitlebarToolSide = 'left' | 'right' +export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void + +interface TitlebarControlsProps extends ComponentProps<'div'> { + leftTools?: readonly TitlebarTool[] + tools?: readonly TitlebarTool[] + commandCenterOpen?: boolean + onOpenSettings: () => void + onOpenSearch: () => void +} + +export function TitlebarControls({ + leftTools = [], + tools = [], + commandCenterOpen = false, + onOpenSettings, + onOpenSearch +}: TitlebarControlsProps) { + const navigate = useNavigate() + const hapticsMuted = useStore($hapticsMuted) + const fileBrowserOpen = useStore($fileBrowserOpen) + const sidebarOpen = useStore($sidebarOpen) + + const toggleHaptics = () => { + if (!hapticsMuted) { + triggerHaptic('tap') + } + + toggleHapticsMuted() + + if (hapticsMuted) { + window.requestAnimationFrame(() => triggerHaptic('success')) + } + } + + const leftToolbarTools: TitlebarTool[] = [ + { + icon: <Codicon name="layout-sidebar-left" />, + id: 'sidebar', + label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar', + onSelect: () => { + triggerHaptic('tap') + toggleSidebarOpen() + } + }, + { + active: commandCenterOpen, + icon: <Codicon name="search" />, + id: 'search', + label: 'Search', + onSelect: () => { + triggerHaptic('open') + onOpenSearch() + }, + title: 'Search sessions, views, and actions' + }, + ...leftTools + ] + + const rightSidebarTool: TitlebarTool = { + active: fileBrowserOpen, + icon: <Codicon name="layout-sidebar-right" />, + id: 'right-sidebar', + label: fileBrowserOpen ? 'Hide right sidebar' : 'Show right sidebar', + onSelect: () => { + triggerHaptic('tap') + toggleFileBrowserOpen() + } + } + + // Static system tools — always pinned to the screen's right edge. + const systemTools: TitlebarTool[] = [ + { + active: hapticsMuted, + icon: hapticsMuted ? <VolumeX /> : <Volume2 />, + id: 'haptics', + label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics', + onSelect: toggleHaptics + }, + { + icon: <Codicon name="settings-gear" />, + id: 'settings', + label: 'Open settings', + onSelect: () => { + triggerHaptic('open') + onOpenSettings() + } + } + ] + + const visibleSystemTools = systemTools.filter(tool => !tool.hidden) + const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings') + const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings') + const visiblePaneTools = tools.filter(tool => !tool.hidden) + + return ( + <> + <div + aria-label="Window controls" + className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]" + > + {leftToolbarTools + .filter(tool => !tool.hidden) + .map(tool => ( + <TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} /> + ))} + </div> + + {/* + Pane-scoped tools (preview's monitor / devtools / refresh / X) render + as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap + to either the static cluster's width (file-browser closed → cluster + sits flush against system tools) or the file-browser pane's width + (file-browser open → cluster sits flush against the file-browser pane, + i.e. at the preview pane's right edge). No margin hacks needed. + */} + {visiblePaneTools.length > 0 && ( + <div + aria-label="Pane controls" + className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]" + > + {visiblePaneTools.map(tool => ( + <TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} /> + ))} + </div> + )} + + <div + aria-label="App controls" + className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]" + > + {visibleSystemToolsBeforeSettings.map(tool => ( + <TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} /> + ))} + <ProfilesMenuButton navigate={navigate} /> + {settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />} + <TitlebarToolButton navigate={navigate} tool={rightSidebarTool} /> + </div> + </> + ) +} + +function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavigate> }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + aria-label="Profiles" + className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')} + onPointerDown={event => event.stopPropagation()} + title="Profiles" + type="button" + > + <Codicon name="account" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-64" sideOffset={8}> + <DropdownMenuLabel> + <div className="text-sm font-medium text-foreground">Profiles</div> + <div className="mt-1 text-xs font-normal leading-4 text-muted-foreground"> + Advanced Hermes environments for separate personas, config, skills, and SOUL.md. + </div> + </DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => { + triggerHaptic('open') + navigate(PROFILES_ROUTE) + }} + > + <Codicon name="account" size="1rem" /> + <span>Manage profiles</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} + +function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) { + const className = cn( + titlebarButtonClass, + 'grid place-items-center bg-transparent select-none [&_svg]:size-4', + tool.active && 'bg-(--ui-control-active-background)! text-foreground!', + tool.className + ) + + if (tool.href) { + return ( + <a + aria-label={tool.label} + className={className} + href={tool.href} + onPointerDown={event => event.stopPropagation()} + rel="noreferrer" + target="_blank" + title={tool.title ?? tool.label} + > + {tool.icon} + </a> + ) + } + + return ( + <button + aria-label={tool.label} + aria-pressed={tool.active ?? undefined} + className={className} + disabled={tool.disabled} + onClick={() => { + if (tool.to) { + navigate(tool.to) + } + + tool.onSelect?.() + }} + onPointerDown={event => event.stopPropagation()} + title={tool.title ?? tool.label} + type="button" + > + {tool.icon} + </button> + ) +} diff --git a/apps/desktop/src/app/shell/titlebar.test.ts b/apps/desktop/src/app/shell/titlebar.test.ts new file mode 100644 index 000000000..8b6f2d867 --- /dev/null +++ b/apps/desktop/src/app/shell/titlebar.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { + TITLEBAR_CONTROL_OFFSET_X, + TITLEBAR_EDGE_INSET, + TITLEBAR_FALLBACK_WINDOW_BUTTON_X, + titlebarControlsPosition +} from './titlebar' + +describe('titlebarControlsPosition', () => { + it('offsets controls from visible traffic lights', () => { + expect(titlebarControlsPosition({ x: 24, y: 10 }).left).toBe(24 + TITLEBAR_CONTROL_OFFSET_X) + }) + + it('pins to the edge when macOS fullscreen hides traffic lights', () => { + expect(titlebarControlsPosition({ x: 24, y: 10 }, true).left).toBe(TITLEBAR_EDGE_INSET) + }) + + it('pins to the edge on Windows/Linux where native controls render on the right', () => { + expect(titlebarControlsPosition(null).left).toBe(TITLEBAR_EDGE_INSET) + }) + + it('uses the macOS fallback while the initial window state is unknown', () => { + expect(titlebarControlsPosition(undefined).left).toBe(TITLEBAR_FALLBACK_WINDOW_BUTTON_X + TITLEBAR_CONTROL_OFFSET_X) + }) +}) diff --git a/apps/desktop/src/app/shell/titlebar.ts b/apps/desktop/src/app/shell/titlebar.ts new file mode 100644 index 000000000..b3ed2b630 --- /dev/null +++ b/apps/desktop/src/app/shell/titlebar.ts @@ -0,0 +1,42 @@ +import type { HermesConnection } from '@/global' + +export const TITLEBAR_HEIGHT = 34 +export const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 +export const TITLEBAR_ICON_SIZE = 12 +export const TITLEBAR_CONTROL_OFFSET_X = 74 +export const TITLEBAR_CONTROL_HEIGHT = 22 +export const TITLEBAR_CONTROLS_TOP = (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2 +export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24 +// Edge inset used when no left-side native controls take up that space — +// Windows/Linux (native overlay is on the right) and macOS fullscreen +// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding. +export const TITLEBAR_EDGE_INSET = 14 + +export const titlebarButtonClass = + 'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground' + +export const titlebarHeaderBaseClass = + 'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]' + +export const titlebarHeaderShadowClass = + "after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']" + +export function titlebarControlsPosition( + windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined, + isFullscreen = false +) { + const top = Math.max(0, TITLEBAR_CONTROLS_TOP) + + // No left-side native controls to dodge: + // - Windows/Linux: native min/max/close render on the right via titleBarOverlay. + // - macOS fullscreen: traffic lights are hidden. + // In both cases, pin the cluster to the edge with a small inset. + if (windowButtonPosition === null || isFullscreen) { + return { left: TITLEBAR_EDGE_INSET, top } + } + + return { + left: (windowButtonPosition?.x ?? TITLEBAR_FALLBACK_WINDOW_BUTTON_X) + TITLEBAR_CONTROL_OFFSET_X, + top + } +} diff --git a/apps/desktop/src/app/shell/use-group-registry.ts b/apps/desktop/src/app/shell/use-group-registry.ts new file mode 100644 index 000000000..ef78dfde0 --- /dev/null +++ b/apps/desktop/src/app/shell/use-group-registry.ts @@ -0,0 +1,39 @@ +import { useCallback, useMemo, useState } from 'react' + +type Side = 'left' | 'right' +type Groups<T> = Record<Side, Record<string, readonly T[]>> + +export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void + +interface GroupRegistry<T> { + flat: { left: T[]; right: T[] } + set: GroupSetter<T> +} + +export function useGroupRegistry<T>(): GroupRegistry<T> { + const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} }) + + const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => { + setGroups(current => { + const next = { ...current, [side]: { ...current[side] } } + + if (items.length === 0) { + delete next[side][id] + } else { + next[side][id] = items + } + + return next + }) + }, []) + + const flat = useMemo( + () => ({ + left: Object.values(groups.left).flat(), + right: Object.values(groups.right).flat() + }), + [groups] + ) + + return { flat, set } +} diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx new file mode 100644 index 000000000..f02bd5ffb --- /dev/null +++ b/apps/desktop/src/app/skills/index.tsx @@ -0,0 +1,312 @@ +import type * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Switch } from '@/components/ui/switch' +import { TextTab, TextTabMeta } from '@/components/ui/text-tab' +import { getSkills, getToolsets, toggleSkill } from '@/hermes' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import type { SkillInfo, ToolsetInfo } from '@/types/hermes' + +import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { PageSearchShell } from '../page-search-shell' +import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' + +const SKILLS_MODES = ['skills', 'toolsets'] as const +type SkillsMode = (typeof SKILLS_MODES)[number] + +function categoryFor(skill: SkillInfo): string { + return asText(skill.category) || 'general' +} + +function filteredSkills(skills: SkillInfo[], query: string, category: string | null): SkillInfo[] { + const q = query.trim().toLowerCase() + + return skills + .filter(skill => { + if (category && categoryFor(skill) !== category) { + return false + } + + if (!q) { + return true + } + + return includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q) + }) + .sort((a, b) => asText(a.name).localeCompare(asText(b.name))) +} + +function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[] { + const q = query.trim().toLowerCase() + + return toolsets + .filter(toolset => { + if (!q) { + return true + } + + return ( + includesQuery(toolset.name, q) || + includesQuery(toolset.label, q) || + includesQuery(toolset.description, q) || + toolNames(toolset).some(name => includesQuery(name, q)) + ) + }) + .sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name))) +} + +interface SkillsViewProps extends React.ComponentProps<'section'> { + setStatusbarItemGroup?: SetStatusbarItemGroup +} + +export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) { + const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills') + + const [query, setQuery] = useState('') + const [skills, setSkills] = useState<SkillInfo[] | null>(null) + const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null) + const [activeCategory, setActiveCategory] = useState<string | null>(null) + const [refreshing, setRefreshing] = useState(false) + const [savingSkill, setSavingSkill] = useState<string | null>(null) + + const refreshCapabilities = useCallback(async () => { + setRefreshing(true) + + try { + const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()]) + setSkills(nextSkills) + setToolsets(nextToolsets) + } catch (err) { + notifyError(err, 'Skills failed to load') + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + void refreshCapabilities() + }, [refreshCapabilities]) + + const categories = useMemo(() => { + if (!skills) { + return [] + } + + const counts = new Map<string, number>() + + for (const skill of skills) { + const key = categoryFor(skill) + counts.set(key, (counts.get(key) || 0) + 1) + } + + return Array.from(counts.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, count]) => ({ key, count })) + }, [skills]) + + const visibleSkills = useMemo( + () => (skills ? filteredSkills(skills, query, mode === 'skills' ? activeCategory : null) : []), + [activeCategory, mode, query, skills] + ) + + const visibleToolsets = useMemo(() => (toolsets ? filteredToolsets(toolsets, query) : []), [query, toolsets]) + + const skillGroups = useMemo(() => { + const groups = new Map<string, SkillInfo[]>() + + for (const skill of visibleSkills) { + const key = categoryFor(skill) + groups.set(key, [...(groups.get(key) || []), skill]) + } + + return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)) + }, [visibleSkills]) + + const totalSkills = skills?.length || 0 + const enabledToolsets = toolsets?.filter(toolset => toolset.enabled).length || 0 + + async function handleToggleSkill(skill: SkillInfo, enabled: boolean) { + setSavingSkill(skill.name) + + try { + await toggleSkill(skill.name, enabled) + setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current) + notify({ + kind: 'success', + title: enabled ? 'Skill enabled' : 'Skill disabled', + message: `${skill.name} applies to new sessions.` + }) + } catch (err) { + notifyError(err, `Failed to update ${skill.name}`) + } finally { + setSavingSkill(null) + } + } + + return ( + <PageSearchShell + {...props} + filters={ + <> + <div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1"> + <TextTab active={mode === 'skills'} onClick={() => setMode('skills')}> + Skills + </TextTab> + <TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}> + Toolsets + </TextTab> + </div> + {mode === 'skills' && categories.length > 0 && ( + <div className="flex flex-wrap justify-center gap-x-2 gap-y-1"> + <TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}> + All <TextTabMeta>{totalSkills}</TextTabMeta> + </TextTab> + {categories.map(category => ( + <TextTab + active={activeCategory === category.key} + key={category.key} + onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)} + > + {prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta> + </TextTab> + ))} + </div> + )} + </> + } + onSearchChange={setQuery} + searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'} + searchTrailingAction={ + <Button + aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'} + className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground" + disabled={refreshing} + onClick={() => void refreshCapabilities()} + size="icon-xs" + title={refreshing ? 'Refreshing skills' : 'Refresh skills'} + type="button" + variant="ghost" + > + <Codicon name="refresh" size="0.875rem" spinning={refreshing} /> + </Button> + } + searchValue={query} + > + {!skills || !toolsets ? ( + <PageLoader label="Loading capabilities..." /> + ) : mode === 'skills' ? ( + <div className="h-full overflow-y-auto px-4 py-3"> + {visibleSkills.length === 0 ? ( + <EmptyState description="Try a broader search or different category." title="No skills found" /> + ) : ( + <div className="space-y-4"> + {skillGroups.map(([category, list]) => ( + <div className="space-y-1.5" key={category}> + <div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + {prettyName(category)} + </div> + <div className="divide-y divide-(--ui-stroke-quaternary)"> + {list.map(skill => ( + <div + className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" + key={skill.name} + > + <div className="min-w-0"> + <div className="truncate text-sm font-medium">{skill.name}</div> + <p className="mt-0.5 text-xs text-muted-foreground"> + {asText(skill.description) || 'No description.'} + </p> + </div> + <Switch + checked={skill.enabled} + disabled={savingSkill === skill.name} + onCheckedChange={checked => void handleToggleSkill(skill, checked)} + /> + </div> + ))} + </div> + </div> + ))} + </div> + )} + </div> + ) : ( + <div className="h-full overflow-y-auto px-4 py-3"> + {visibleToolsets.length === 0 ? ( + <EmptyState description="Try a broader search query." title="No toolsets found" /> + ) : ( + <div className="space-y-2"> + <div className="text-xs text-muted-foreground"> + {enabledToolsets}/{toolsets.length} toolsets enabled + </div> + <div className="divide-y divide-(--ui-stroke-quaternary)"> + {visibleToolsets.map(toolset => { + const tools = toolNames(toolset) + const label = asText(toolset.label || toolset.name) + + return ( + <div className="px-0 py-2.5" key={toolset.name}> + <div className="flex items-center justify-between gap-2"> + <div className="truncate text-sm font-medium">{label}</div> + <div className="flex items-center gap-1.5"> + <StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill> + <StatusPill active={toolset.configured}> + {toolset.configured ? 'Configured' : 'Needs keys'} + </StatusPill> + </div> + </div> + <p className="mt-1 text-xs text-muted-foreground"> + {asText(toolset.description) || 'No description.'} + </p> + {tools.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1"> + {tools.map(name => ( + <span + className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)" + key={name} + > + {name} + </span> + ))} + </div> + )} + </div> + ) + })} + </div> + </div> + )} + </div> + )} + </PageSearchShell> + ) +} + +function StatusPill({ active, children }: { active: boolean; children: string }) { + return ( + <span + className={cn( + 'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]', + active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)' + )} + > + {children} + </span> + ) +} + +function EmptyState({ title, description }: { title: string; description: string }) { + return ( + <div className="grid min-h-52 place-items-center text-center"> + <div> + <div className="text-sm font-medium">{title}</div> + <div className="mt-1 text-xs text-muted-foreground">{description}</div> + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts new file mode 100644 index 000000000..246076bd3 --- /dev/null +++ b/apps/desktop/src/app/types.ts @@ -0,0 +1,76 @@ +import type * as React from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' + +export interface ContextSuggestion { + text: string + display: string + meta?: string +} + +export interface ImageAttachResponse { + attached?: boolean + path?: string + text?: string + message?: string +} + +export interface ImageDetachResponse { + detached?: boolean + count?: number +} + +export interface SlashExecResponse { + output?: string + warning?: string +} + +export interface ExecCommandDispatchResponse { + type: 'exec' | 'plugin' + output?: string +} + +export interface AliasCommandDispatchResponse { + type: 'alias' + target: string +} + +export interface SkillCommandDispatchResponse { + type: 'skill' + name: string + message?: string +} + +export interface SendCommandDispatchResponse { + type: 'send' + message: string +} + +export type CommandDispatchResponse = + | ExecCommandDispatchResponse + | AliasCommandDispatchResponse + | SkillCommandDispatchResponse + | SendCommandDispatchResponse + +export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills' + +export interface SidebarNavItem { + id: SidebarNavId + label: string + icon: React.ComponentType<{ className?: string }> + route?: string + action?: 'new-session' +} + +export interface ClientSessionState { + storedSessionId: string | null + messages: ChatMessage[] + branch: string + cwd: string + busy: boolean + awaitingResponse: boolean + streamId: string | null + sawAssistantPayload: boolean + pendingBranchGroup: string | null + interrupted: boolean +} diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx new file mode 100644 index 000000000..39cbe97c1 --- /dev/null +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -0,0 +1,393 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { writeClipboardText } from '@/components/ui/copy-button' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' +import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' +import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $updateApply, + $updateChecking, + $updateOverlayOpen, + $updateStatus, + applyUpdates, + checkUpdates, + resetUpdateApplyState, + setUpdateOverlayOpen, + type UpdateApplyState +} from '@/store/updates' + +const STAGE_LABELS: Record<DesktopUpdateStage, string> = { + idle: 'Getting ready…', + prepare: 'Getting ready…', + fetch: 'Downloading…', + pull: 'Almost there…', + pydeps: 'Finishing up…', + restart: 'Restarting Hermes…', + manual: 'Update from your terminal', + error: 'Update paused' +} + +function totalItems(groups: readonly CommitGroup[]) { + return groups.reduce((sum, g) => sum + g.items.length, 0) +} + +export function UpdatesOverlay() { + const open = useStore($updateOverlayOpen) + const status = useStore($updateStatus) + const checking = useStore($updateChecking) + const apply = useStore($updateApply) + + useEffect(() => { + if (open && !status && !checking) { + void checkUpdates() + } + }, [checking, open, status]) + + const behind = status?.behind ?? 0 + + const phase: 'idle' | 'applying' | 'manual' | 'error' = + apply.stage === 'manual' + ? 'manual' + : apply.applying || apply.stage === 'restart' + ? 'applying' + : apply.stage === 'error' + ? 'error' + : 'idle' + + const handleClose = (next: boolean) => { + if (phase === 'applying') { + return + } + + setUpdateOverlayOpen(next) + + if (!next && (apply.stage === 'error' || apply.stage === 'restart' || apply.stage === 'manual')) { + resetUpdateApplyState() + } + } + + const handleInstall = () => { + void applyUpdates() + } + + return ( + <Dialog onOpenChange={handleClose} open={open}> + <DialogContent + className="max-w-sm overflow-hidden border-border/70 p-0 gap-0" + showCloseButton={phase !== 'applying'} + > + {phase === 'applying' && <ApplyingView apply={apply} />} + + {phase === 'manual' && ( + <ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} /> + )} + + {phase === 'error' && ( + <ErrorView message={apply.message} onDismiss={() => handleClose(false)} onRetry={handleInstall} /> + )} + + {phase === 'idle' && ( + <IdleView + behind={behind} + checking={checking} + commits={status?.commits ?? []} + onInstall={handleInstall} + onLater={() => handleClose(false)} + onRetryCheck={() => void checkUpdates()} + status={status} + /> + )} + </DialogContent> + </Dialog> + ) +} + +function IdleView({ + behind, + checking, + commits, + onInstall, + onLater, + onRetryCheck, + status +}: { + behind: number + checking: boolean + commits: readonly DesktopUpdateCommit[] + onInstall: () => void + onLater: () => void + onRetryCheck: () => void + status: DesktopUpdateStatus | null +}) { + if (!status && checking) { + return ( + <CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" /> + ) + } + + if (!status) { + return ( + <CenteredStatus + action={ + <Button onClick={onRetryCheck} size="sm"> + Try again + </Button> + } + icon={<AlertCircle className="size-6 text-muted-foreground" />} + title="Couldn’t check for updates" + /> + ) + } + + if (!status.supported) { + return ( + <CenteredStatus + action={ + <Button onClick={onLater} size="sm" variant="outline"> + Close + </Button> + } + body={status.message ?? 'This version of Hermes can’t update itself from inside the app.'} + icon={<AlertCircle className="size-6 text-muted-foreground" />} + title="Update not available" + /> + ) + } + + if (status.error) { + return ( + <CenteredStatus + action={ + <Button disabled={checking} onClick={onRetryCheck} size="sm"> + Try again + </Button> + } + body="Check your connection and try again." + icon={<AlertCircle className="size-6 text-muted-foreground" />} + title="Couldn’t check for updates" + /> + ) + } + + if (behind === 0) { + return ( + <CenteredStatus + action={ + <Button onClick={onLater} size="sm" variant="outline"> + Close + </Button> + } + body="You’re running the latest version." + icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />} + title="You’re all set" + /> + ) + } + + const groups = buildCommitChangelog(commits) + const shownItems = totalItems(groups) + const remaining = Math.max(0, behind - shownItems) + + return ( + <div className="grid gap-5 px-6 pb-6 pt-7 pr-8"> + <div className="flex flex-col items-center gap-3 text-center"> + <span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"> + <Sparkles className="size-7" /> + </span> + + <DialogTitle className="text-center text-xl">New update available</DialogTitle> + <DialogDescription className="text-center text-sm"> + A new version of Hermes is ready to install. + </DialogDescription> + </div> + + <div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3"> + {groups.map(group => ( + <div key={group.id}> + <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p> + <ul className="mt-1.5 grid gap-1.5 text-sm text-foreground"> + {group.items.map(item => ( + <li className="flex items-start gap-2" key={item}> + <span aria-hidden className="mt-2 inline-block size-1.5 shrink-0 rounded-full bg-primary" /> + <span className="leading-snug">{item}</span> + </li> + ))} + </ul> + </div> + ))} + </div> + + <div className="grid gap-2"> + <Button className="h-10 text-sm font-semibold" onClick={onInstall} size="default"> + Update now + </Button> + <button + className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" + onClick={onLater} + type="button" + > + Maybe later + </button> + </div> + + {remaining > 0 && ( + <p className="text-center text-xs text-muted-foreground"> + + {remaining} more change{remaining === 1 ? '' : 's'} included. + </p> + )} + </div> + ) +} + +function ManualView({ command, onDone }: { command: string; onDone: () => void }) { + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + void writeClipboardText(command).then(() => { + setCopied(true) + window.setTimeout(() => setCopied(false), 1800) + }) + } + + return ( + <div className="grid gap-5 px-6 pb-6 pt-7 pr-8"> + <div className="flex flex-col items-center gap-3 text-center"> + <span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"> + <Terminal className="size-7" /> + </span> + + <DialogTitle className="text-center text-xl">Update from your terminal</DialogTitle> + <DialogDescription className="text-center text-sm"> + You installed Hermes from the command line, so updates run there too. Paste this into your terminal: + </DialogDescription> + </div> + + <button + type="button" + onClick={handleCopy} + className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50" + > + <code className="select-all font-mono text-sm text-foreground"> + <span className="text-muted-foreground">$ </span> + {command} + </code> + <span className="flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground"> + {copied ? ( + <> + <Check className="size-3.5 text-emerald-600 dark:text-emerald-400" /> + Copied + </> + ) : ( + <> + <Copy className="size-3.5" /> + Copy + </> + )} + </span> + </button> + + <p className="text-center text-xs text-muted-foreground"> + Hermes will pick up the new version next time you launch it. + </p> + + <Button className="h-10 text-sm font-semibold" onClick={onDone} variant="outline"> + Done + </Button> + </div> + ) +} + +function ApplyingView({ apply }: { apply: UpdateApplyState }) { + const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…' + + const percent = + typeof apply.percent === 'number' && Number.isFinite(apply.percent) + ? Math.max(2, Math.min(100, Math.round(apply.percent))) + : null + + return ( + <div className="grid gap-5 px-6 pb-6 pt-7"> + <div className="flex flex-col items-center gap-3 text-center"> + <span className="relative flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"> + <Loader2 className="size-7 animate-spin" /> + </span> + + <DialogTitle className="text-center text-xl">{label}</DialogTitle> + <DialogDescription className="text-center text-sm"> + The Hermes updater will take over in its own window and reopen Hermes when it’s done. + </DialogDescription> + </div> + + <div className="h-2 overflow-hidden rounded-full bg-muted"> + <div + className={cn( + 'h-full rounded-full bg-primary transition-[width] duration-300 ease-out', + percent === null && 'w-1/3 animate-pulse' + )} + style={percent !== null ? { width: `${percent}%` } : undefined} + /> + </div> + + <p className="text-center text-xs text-muted-foreground">Hermes will close to apply the update.</p> + </div> + ) +} + +function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) { + return ( + <div className="grid gap-5 px-6 pb-6 pt-7 pr-8"> + <div className="flex flex-col items-center gap-3 text-center"> + <span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive"> + <AlertCircle className="size-7" /> + </span> + + <DialogTitle className="text-center text-xl">Update didn’t finish</DialogTitle> + <DialogDescription className="text-center text-sm"> + {message || 'No worries — nothing was lost. You can try again now.'} + </DialogDescription> + </div> + + <div className="grid gap-2"> + <Button className="h-10 text-sm font-semibold" onClick={onRetry}> + Try again + </Button> + <button + className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" + onClick={onDismiss} + type="button" + > + Not now + </button> + </div> + </div> + ) +} + +function CenteredStatus({ + action, + body, + icon, + title +}: { + action?: React.ReactNode + body?: string + icon: React.ReactNode + title: string +}) { + return ( + <div className="grid gap-4 px-6 pb-6 pt-8 pr-8"> + <div className="flex flex-col items-center gap-3 text-center"> + <span className="flex size-14 items-center justify-center rounded-2xl bg-muted/40">{icon}</span> + + <DialogTitle className="text-center text-lg">{title}</DialogTitle> + {body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>} + </div> + + {action && <div className="flex justify-center">{action}</div>} + </div> + ) +} diff --git a/apps/desktop/src/components/Backdrop.tsx b/apps/desktop/src/components/Backdrop.tsx new file mode 100644 index 000000000..1ced2f4d1 --- /dev/null +++ b/apps/desktop/src/components/Backdrop.tsx @@ -0,0 +1,114 @@ +import { Leva, useControls } from 'leva' +import { type CSSProperties, useEffect, useState } from 'react' + +const BLEND_MODES = [ + 'normal', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity' +] as const + +type BlendMode = (typeof BLEND_MODES)[number] +const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}` + +export function Backdrop() { + const [controlsOpen, setControlsOpen] = useState(false) + + useEffect(() => { + if (!import.meta.env.DEV) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + + const editing = + target?.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement + + if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) { + return + } + + if (event.shiftKey && event.code === 'KeyY') { + setControlsOpen(open => !open) + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + const shape = useControls( + 'UI / Shape', + { radiusScalar: { value: 0.2, min: 0, max: 2, step: 0.1, label: 'radius scalar' } }, + { collapsed: true } + ) + + useEffect(() => { + document.documentElement.style.setProperty('--radius-scalar', String(shape.radiusScalar)) + }, [shape.radiusScalar]) + + const statue = useControls( + 'Backdrop / Statue', + { + enabled: { value: true, label: 'on' }, + opacity: { value: 0.025, min: 0, max: 1, step: 0.005 }, + blendMode: { value: 'difference' as BlendMode, options: BLEND_MODES, label: 'blend' }, + invert: { value: true, label: 'invert color' }, + saturate: { value: 1, min: 0, max: 3, step: 0.05, label: 'saturate' }, + brightness: { value: 1, min: 0, max: 2, step: 0.05, label: 'brightness' }, + objectPosition: { + value: 'top left', + options: ['top left', 'top right', 'bottom left', 'bottom right', 'center', 'top', 'bottom', 'left', 'right'], + label: 'position' + }, + scale: { value: 160, min: 100, max: 300, step: 5, label: 'height (dvh)' } + }, + { collapsed: true } + ) + + return ( + <> + <Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} /> + + {statue.enabled && ( + <div + aria-hidden + className="pointer-events-none absolute inset-0 z-2" + style={{ + mixBlendMode: statue.blendMode as CSSProperties['mixBlendMode'], + opacity: statue.opacity + }} + > + <img + alt="" + className="w-auto min-w-dvw object-cover" + fetchPriority="low" + src={assetPath('ds-assets/filler-bg0.jpg')} + style={{ + height: `${statue.scale}dvh`, + objectPosition: statue.objectPosition, + filter: `invert(calc(${statue.invert ? 1 : 0} * var(--backdrop-invert-mul, 1))) saturate(${statue.saturate}) brightness(${statue.brightness})` + }} + /> + </div> + )} + </> + ) +} diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx new file mode 100644 index 000000000..266e554ad --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -0,0 +1,287 @@ +'use client' + +import { type ToolCallMessagePartProps } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react' + +import { ToolFallback } from '@/components/assistant-ui/tool-fallback' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { triggerHaptic } from '@/lib/haptics' +import { HelpCircle, Loader2, PencilLine } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' + +interface ClarifyArgs { + question?: string + choices?: string[] | null +} + +function readClarifyArgs(args: unknown): ClarifyArgs { + if (!args || typeof args !== 'object') { + return {} + } + + const row = args as Record<string, unknown> + const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null + + return { + question: typeof row.question === 'string' ? row.question : undefined, + choices: choices && choices.length > 0 ? choices : null + } +} + +export const ClarifyTool = (props: ToolCallMessagePartProps) => { + const isPending = props.result === undefined + + // Once Hermes records an answer, fall back to the standard tool block so + // the past Q/A renders consistently with every other tool in the thread. + if (!isPending) { + return <ToolFallback {...props} /> + } + + return <ClarifyToolPending {...props} /> +} + +function ClarifyToolPending({ args }: ToolCallMessagePartProps) { + const request = useStore($clarifyRequest) + const gateway = useStore($gateway) + const fromArgs = useMemo(() => readClarifyArgs(args), [args]) + + const matchingRequest = useMemo(() => { + if (!request) { + return null + } + + if (fromArgs.question && request.question && fromArgs.question !== request.question) { + return null + } + + return request + }, [fromArgs.question, request]) + + const question = fromArgs.question || matchingRequest?.question || '' + + const choices = useMemo( + () => fromArgs.choices ?? matchingRequest?.choices ?? [], + [fromArgs.choices, matchingRequest?.choices] + ) + + const hasChoices = choices.length > 0 + + const [typing, setTyping] = useState(false) + const [draft, setDraft] = useState('') + const [submitting, setSubmitting] = useState(false) + const textareaRef = useRef<HTMLTextAreaElement | null>(null) + + // Race: tool.start fires a tick before clarify.request, so request_id + // arrives slightly after the tool block mounts. Show the question (from + // args) but disable submit until we have the request id from the gateway. + const ready = Boolean(matchingRequest?.requestId) + + const respond = useCallback( + async (answer: string) => { + if (!ready || !matchingRequest) { + notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response') + + return + } + + if (!gateway) { + notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response') + + return + } + + setSubmitting(true) + + try { + await gateway.request<{ ok?: boolean }>('clarify.respond', { + request_id: matchingRequest.requestId, + answer + }) + triggerHaptic('submit') + clearClarifyRequest(matchingRequest.requestId) + // The matching tool.complete will land shortly after, swapping this + // panel for the ToolFallback view above. + } catch (error) { + notifyError(error, 'Could not send clarify response') + setSubmitting(false) + } + }, + [gateway, matchingRequest, ready] + ) + + const handleTextareaKey = useCallback( + (event: KeyboardEvent<HTMLTextAreaElement>) => { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + const trimmed = draft.trim() + + if (trimmed) { + void respond(trimmed) + } + } + }, + [draft, respond] + ) + + const handleSubmitFreeform = useCallback( + (event: FormEvent<HTMLFormElement>) => { + event.preventDefault() + const trimmed = draft.trim() + + if (trimmed) { + void respond(trimmed) + } + }, + [draft, respond] + ) + + const handleChoiceKey = useCallback( + (event: KeyboardEvent<HTMLDivElement>) => { + if (typing || submitting) { + return + } + + const numeric = Number.parseInt(event.key, 10) + + if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) { + event.preventDefault() + void respond(choices[numeric - 1]!) + } + }, + [choices, respond, submitting, typing] + ) + + return ( + <div + className={cn( + 'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm', + 'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]' + )} + data-slot="clarify-inline" + > + <div className="flex items-start gap-2.5"> + <span + aria-hidden + className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15" + > + <HelpCircle className="size-3.5" /> + </span> + <div className="grid flex-1 gap-0.5"> + <span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85"> + Hermes is asking + </span> + <span className="whitespace-pre-wrap leading-snug text-foreground"> + {question || <em className="text-muted-foreground/70">Loading question…</em>} + </span> + </div> + </div> + + {!typing && hasChoices && ( + <div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group"> + {choices.map((choice, index) => ( + <button + className={cn( + 'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95', + 'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55' + )} + data-choice + disabled={!ready || submitting} + key={`${index}-${choice}`} + onClick={() => void respond(choice)} + type="button" + > + <span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background"> + {index + 1} + </span> + <span className="flex-1 wrap-anywhere">{choice}</span> + </button> + ))} + <button + className={cn( + 'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground', + 'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground' + )} + disabled={submitting} + onClick={() => { + setTyping(true) + window.setTimeout(() => textareaRef.current?.focus({ preventScroll: true }), 0) + }} + type="button" + > + <span + aria-hidden + className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground" + > + <PencilLine className="size-3" /> + </span> + <span className="flex-1">Other (type your answer)</span> + </button> + </div> + )} + + {(typing || !hasChoices) && ( + <form className="grid gap-2" onSubmit={handleSubmitFreeform}> + <Textarea + className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm" + disabled={submitting} + onChange={event => setDraft(event.target.value)} + onKeyDown={handleTextareaKey} + placeholder="Type your answer…" + ref={textareaRef} + value={draft} + /> + <div className="flex items-center justify-between gap-2"> + <span className="text-[0.6875rem] text-muted-foreground/85">⌘/Ctrl + Enter to send</span> + <div className="flex items-center gap-1.5"> + {hasChoices && ( + <Button + disabled={submitting} + onClick={() => { + setTyping(false) + setDraft('') + }} + size="sm" + type="button" + variant="ghost" + > + Back + </Button> + )} + <Button + disabled={!ready || submitting} + onClick={() => void respond('')} + size="sm" + type="button" + variant="ghost" + > + Skip + </Button> + <Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit"> + {submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'} + </Button> + </div> + </div> + </form> + )} + + {!typing && hasChoices && ( + <div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85"> + <span>1–{choices.length} to pick</span> + <button + className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50" + disabled={!ready || submitting} + onClick={() => void respond('')} + type="button" + > + Skip + </button> + </div> + )} + </div> + ) +} diff --git a/apps/desktop/src/components/assistant-ui/directive-text.test.ts b/apps/desktop/src/components/assistant-ui/directive-text.test.ts new file mode 100644 index 000000000..60c89f18b --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/directive-text.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import { formatRefValue, hermesDirectiveFormatter } from './directive-text' + +describe('formatRefValue', () => { + it('leaves simple paths untouched', () => { + expect(formatRefValue('src/index.ts')).toBe('src/index.ts') + expect(formatRefValue('https://example.com/post')).toBe('https://example.com/post') + }) + + it('wraps paths with whitespace in backticks', () => { + expect(formatRefValue('apple-touch-icon (1).png')).toBe('`apple-touch-icon (1).png`') + }) + + it('falls back to double quotes when value contains backticks', () => { + expect(formatRefValue('weird `name` (1).md')).toBe('"weird `name` (1).md"') + }) +}) + +describe('hermesDirectiveFormatter.parse', () => { + it('keeps quoted file paths whole when parsing', () => { + const segments = hermesDirectiveFormatter.parse('see @image:`apple-touch-icon (1).png` for the icon') + + expect(segments).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'mention', type: 'image', label: 'apple-touch-icon (1).png', id: 'apple-touch-icon (1).png' }, + { kind: 'text', text: ' for the icon' } + ]) + }) + + it('still parses unquoted paths', () => { + const segments = hermesDirectiveFormatter.parse('@file:src/main.tsx the entry point') + + expect(segments).toEqual([ + { kind: 'mention', type: 'file', label: 'main.tsx', id: 'src/main.tsx' }, + { kind: 'text', text: ' the entry point' } + ]) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx new file mode 100644 index 000000000..9189356dc --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -0,0 +1,376 @@ +'use client' + +import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core' +import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react' +import type { FC } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' + +import { ZoomableImage } from '@/components/chat/zoomable-image' +import { extractEmbeddedImages } from '@/lib/embedded-images' + +const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal'] as const +type HermesRefType = (typeof HERMES_REF_TYPES)[number] + +/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24). + * Used both by the rendered <DirectiveIcon> and the raw SVG markup the + * contenteditable composer embeds via `directiveIconSvg`. */ +const ICON_PATHS: Record<HermesRefType, string[]> = { + file: [ + 'M14 3v4a1 1 0 0 0 1 1h4', + 'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2', + 'M9 9l1 0', + 'M9 13l6 0', + 'M9 17l6 0' + ], + folder: [ + 'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2' + ], + url: [ + 'M9 15l6 -6', + 'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464', + 'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463' + ], + image: [ + 'M15 8h.01', + 'M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12', + 'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5', + 'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3' + ], + tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'], + line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'], + terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'] +} + +const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'] + +const SVG_ATTRS = + 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"' + +const iconPathsFor = (type: string) => ICON_PATHS[type as HermesRefType] ?? ICON_FALLBACK + +/** SVG markup string for embedding directly in HTML (composer contenteditable). */ +export function directiveIconSvg(type: string) { + const inner = iconPathsFor(type) + .map(d => `<path d="${d}"/>`) + .join('') + + return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>` +} + +export function directiveIconElement(type: string) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.setAttribute('class', 'size-3 shrink-0 opacity-80') + svg.setAttribute('fill', 'none') + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('stroke-linecap', 'round') + svg.setAttribute('stroke-linejoin', 'round') + svg.setAttribute('stroke-width', '2') + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + + for (const d of iconPathsFor(type)) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('d', d) + svg.append(path) + } + + return svg +} + +const DirectiveIcon: FC<{ type: string }> = ({ type }) => ( + <svg + className="size-3 shrink-0 opacity-80" + fill="none" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + {iconPathsFor(type).map(d => ( + <path d={d} key={d} /> + ))} + </svg> +) + +/** Shared chip styling — used by both the rendered <DirectiveChip> and the + * raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain + * muted-foreground text so chips read as quiet tags on any bubble color. */ +export const DIRECTIVE_CHIP_CLASS = + 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' + +/** + * Parses our composer's `@type:value` references into directive segments + * so they render as inline chips in user messages instead of raw text. + * + * Supported types: file, folder, url, image. Anything else stays plain text. + * + * Mirrors the Python `agent/context_references.REFERENCE_PATTERN` syntax: + * the value may be wrapped in backticks, single quotes, or double quotes so + * paths with spaces/parens/etc. survive parsing intact. + */ +const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g + +const HERMES_DIRECTIVE_RE = new RegExp( + '@(file|folder|url|image|tool|line|terminal):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')', + 'g' +) + +const TRAILING_PUNCTUATION_RE = /[,.;!?]+$/ + +function unwrapRefValue(raw: string): string { + if (raw.length < 2) { + return raw + } + + const head = raw[0] + const tail = raw[raw.length - 1] + + if ((head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")) { + return raw.slice(1, -1) + } + + return raw.replace(TRAILING_PUNCTUATION_RE, '') +} + +function needsQuoting(value: string): boolean { + return /[\s()[\]{}<>"'`]/.test(value) +} + +export function formatRefValue(value: string): string { + if (!needsQuoting(value)) { + return value + } + + if (!value.includes('`')) { + return `\`${value}\`` + } + + if (!value.includes('"')) { + return `"${value}"` + } + + if (!value.includes("'")) { + return `'${value}'` + } + + return value +} + +export const hermesDirectiveFormatter: Unstable_DirectiveFormatter = { + serialize(item: Unstable_TriggerItem): string { + const metadata = item.metadata as { rawText?: unknown; insertId?: unknown } | undefined + const rawText = typeof metadata?.rawText === 'string' ? metadata.rawText : null + const insertId = typeof metadata?.insertId === 'string' ? metadata.insertId : null + + // Live-completion items carry the gateway's original `text` field via metadata. + if (rawText) { + // Palette starters (`@file:` with empty value) — insert verbatim so the + // user can keep typing the path inline. + if (rawText.endsWith(':') && !insertId) { + return rawText + } + + // Simple references like `@diff` / `@staged`. + if (!insertId) { + return rawText + } + + // Typed references with a value — quote when needed. + const kindMatch = rawText.match(/^@([^:]+):/) + const kind = kindMatch?.[1] ?? item.type + + return `@${kind}:${formatRefValue(insertId)}` + } + + // Fallback for legacy callers that pass raw `id` strings. + if (item.id === `${item.type}:`) { + return `@${item.id}` + } + + return `@${item.type}:${formatRefValue(item.id)}` + }, + parse(text: string): readonly Unstable_DirectiveSegment[] { + return parseDirectiveText(text) + } +} + +function parseDirectiveText(text: string): Unstable_DirectiveSegment[] { + const matches = [ + ...Array.from(text.matchAll(CANONICAL_DIRECTIVE_RE)).map(match => ({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + type: match[1] || 'tool', + label: match[2] || match[3] || '', + id: match[3] || match[2] || '' + })), + ...Array.from(text.matchAll(HERMES_DIRECTIVE_RE)).map(match => { + const id = unwrapRefValue(match[2] || '') + + return { + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + type: match[1] || 'file', + label: shortLabel(match[1] as HermesRefType, id), + id + } + }) + ] + .filter(match => match.id) + .sort((a, b) => a.start - b.start) + + const segments: Unstable_DirectiveSegment[] = [] + let cursor = 0 + + for (const match of matches) { + if (match.start < cursor) { + continue + } + + if (match.start > cursor) { + segments.push({ kind: 'text', text: text.slice(cursor, match.start) }) + } + + segments.push({ + kind: 'mention', + type: match.type, + label: match.label, + id: match.id + }) + cursor = match.end + } + + if (cursor < text.length) { + segments.push({ kind: 'text', text: text.slice(cursor) }) + } + + return segments +} + +function shortLabel(type: HermesRefType, id: string): string { + if (type === 'terminal') { + return id || 'terminal' + } + + if (type === 'url') { + try { + const parsed = new URL(id) + + return parsed.hostname || id + } catch { + return id + } + } + + const tail = id.split(/[\\/]/).filter(Boolean).pop() + + return tail || id +} + +/** + * Renders text containing Hermes directives (`@file:...`, `@image:...`) as + * inline chips. Embedded MEDIA images render below as a thumbnail row. + */ +export function DirectiveContent({ text }: { text: string }) { + const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text]) + const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText]) + + return ( + <span className="whitespace-pre-line" data-slot="aui_directive-text"> + {segments.map((segment, index) => + segment.kind === 'text' ? ( + <Fragment key={`t-${index}`}>{segment.text}</Fragment> + ) : segment.type === 'image' ? ( + <DirectiveImage id={segment.id} key={`img-${index}-${segment.id}`} label={segment.label} /> + ) : ( + <DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} /> + ) + )} + {images.length > 0 && ( + <span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images"> + {images.map((src, index) => ( + <ZoomableImage + alt="" + className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain" + draggable={false} + key={`img-${index}`} + slot="aui_embedded-image" + src={src} + /> + ))} + </span> + )} + </span> + ) +} + +/** assistant-ui adapter: same renderer, exposed as a TextMessagePartComponent. */ +export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => ( + <DirectiveContent text={text ?? ''} /> +) + +/** Image refs render as a thumbnail rather than a chip — matches how persisted + * messages render after the backend embeds the data URL, so the UX is stable + * across initial send and refresh. */ +const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => { + const remote = /^(?:https?|data):/i.test(id) + const [src, setSrc] = useState<string | null>(remote ? id : null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + if (remote || !id) { + return + } + + let alive = true + void window.hermesDesktop + ?.readFileDataUrl(id) + .then(url => alive && setSrc(url)) + .catch(() => alive && setFailed(true)) + + return () => { + alive = false + } + }, [id, remote]) + + if (failed) { + return <DirectiveChip id={id} label={label} type="image" /> + } + + if (!src) { + return ( + <span + aria-hidden + className="inline-block size-12 shrink-0 animate-pulse rounded-md bg-[color-mix(in_srgb,currentColor_8%,transparent)]" + /> + ) + } + + return ( + <ZoomableImage + alt={label} + className="max-h-32 max-w-48 rounded-md border border-border/40 object-contain" + draggable={false} + slot="aui_directive-image" + src={src} + /> + ) +} + +const DirectiveChip: FC<{ + type: string + label: string + id: string +}> = ({ type, label, id }) => ( + <span + className={DIRECTIVE_CHIP_CLASS} + data-directive-id={id} + data-directive-type={type} + data-slot="aui_directive-chip" + title={id} + > + <DirectiveIcon type={type} /> + <span className="truncate">{label}</span> + </span> +) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts new file mode 100644 index 000000000..22645ec7c --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' + +import { preprocessMarkdown } from '@/lib/markdown-preprocess' + +describe('preprocessMarkdown', () => { + it('strips inline accidental triple-backtick starts', () => { + const input = [ + 'Working as intended.', + "Here's your scene: ``` http://localhost:8812/", + '', + '- **Multicolored cube**', + '- **Rotates**' + ].join('\n') + + const output = preprocessMarkdown(input) + + expect(output).not.toContain('```') + expect(output).toContain("Here's your scene:") + expect(output).not.toContain('http://localhost:8812/') + expect(output).toContain('- **Multicolored cube**') + }) + + it('demotes invalid fenced prose blocks with closers', () => { + const fence = '```' + + const input = [ + `${fence} http://localhost:8812/`, + '- **Scroll wheel** - zoom', + '- **Right-drag/pan** - disabled', + fence + ].join('\n') + + const output = preprocessMarkdown(input) + + expect(output).not.toContain('```') + expect(output).not.toContain('http://localhost:8812/') + expect(output).toContain('- **Scroll wheel** - zoom') + }) + + it('drops fences around a preview-only URL block', () => { + const fence = '```' + const input = ['Server is back.', '', fence, 'http://localhost:8812/', fence].join('\n') + + const output = preprocessMarkdown(input) + + expect(output).toContain('Server is back.') + expect(output).not.toContain('```') + expect(output).not.toContain('http://localhost:8812/') + }) + + it('demotes prose sentence masquerading as fence info', () => { + const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join( + '\n' + ) + + const output = preprocessMarkdown(input) + + expect(output).not.toContain('```heads') + expect(output).toContain('Heads up - a bunny got added') + expect(output).toContain('- Pure white (`#ffffff`)') + }) + + it('keeps valid code fences intact', () => { + const fence = '```' + const input = [`${fence}ts`, 'const value = 1;', fence].join('\n') + + const output = preprocessMarkdown(input) + + expect(output).toContain('```ts') + expect(output).toContain('const value = 1;') + }) + + it('keeps dangling real code fences during streaming', () => { + const input = ['```ts', 'const value = 1;'].join('\n') + const output = preprocessMarkdown(input) + + expect(output.startsWith('```ts')).toBe(true) + expect(output).toContain('const value = 1;') + }) + + it('demotes dangling prose fences', () => { + const input = ['```', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n') + const output = preprocessMarkdown(input) + + expect(output).not.toContain('```') + expect(output).toContain('- Pure white (`#ffffff`)') + }) + + it('autolinks raw urls in prose', () => { + const output = preprocessMarkdown( + 'Book here:\nhttps://www.getyourguide.com/culebra-island-l145468/from-fajardo-tour-t19894/' + ) + + expect(output).toContain('<https://www.getyourguide.com/culebra-island-l145468/from-fajardo-tour-t19894/>') + }) + + it('strips orphan numeric citation markers outside code spans', () => { + const output = preprocessMarkdown('This is the source[0], but keep `items[0]` untouched.') + + expect(output).toContain('source,') + expect(output).not.toContain('source[0]') + expect(output).toContain('`items[0]`') + }) + + it('demotes title/url blocks wrapped in malformed inline fences', () => { + const input = [ + '**🚢 TOMORROW (Fajardo, crystal clear cays, pickup avail):**', + '', + 'Icacos Full-Day Catamaran — 6hr, $140, small group, pickup```', + 'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/', + '```Sail Getaway Luxury Cat (Cordillera Cays, water slide, unlimited rum) — 6hr, $195```', + 'https://www.getyourguide.com/fajardo-l882/icacos-all-inclusive-sailing-catamaran-beach-and-snorkel-t466138/' + ].join('\n') + + const output = preprocessMarkdown(input) + + expect(output).not.toContain('```') + expect(output).toContain('Sail Getaway Luxury Cat') + expect(output).toContain( + '<https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/>' + ) + expect(output).toContain( + '<https://www.getyourguide.com/fajardo-l882/icacos-all-inclusive-sailing-catamaran-beach-and-snorkel-t466138/>' + ) + }) + + it('autolinks urls glued to prices and removes orphan fence tails', () => { + const input = [ + '**🐢 TODAY (from San Juan, no driving):**', + '', + 'Sea Turtles & Manatees Snorkel + Free Rum — 1.5hr,', + '~$56```https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/ Old San Juan Sunset Cruise w/ Drinks + Hotel Pickup — 1.5hr, ~$99 (drinks, no snorkel)```', + 'https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/' + ].join('\n') + + const output = preprocessMarkdown(input) + + expect(output).not.toContain('```') + // Currency dollar amounts get escaped to `\$` in the preprocessor + // so they don't get parsed as math delimiters by remark-math (we + // enable singleDollarTextMath, which would otherwise greedy-match + // `$56...$99` as one big inline math span). The escape is invisible + // to the user — `\$` renders as a literal `$` in the final output. + expect(output).toContain( + '~\\$56<https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/> Old San Juan Sunset Cruise' + ) + expect(output).toContain( + '<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>' + ) + }) + + it('demotes url-only fenced blocks to clickable markdown links', () => { + const input = [ + 'Sea Turtles & Manatees Snorkel + Free Rum — 1.5hr, ~$56', + '```', + 'https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/', + '```', + '', + 'Old San Juan Sunset Cruise w/ Drinks + Hotel Pickup — 1.5hr, ~$99', + '```', + 'https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/', + '```' + ].join('\n') + + const output = preprocessMarkdown(input) + + expect(output).not.toContain('```') + expect(output).toContain( + '<https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/>' + ) + expect(output).toContain( + '<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>' + ) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx new file mode 100644 index 000000000..fdd7a95a4 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -0,0 +1,386 @@ +'use client' + +import { TextMessagePartProvider, useAuiState, useMessagePartText } from '@assistant-ui/react' +import { + type StreamdownTextComponents, + StreamdownTextPrimitive, + type SyntaxHighlighterProps +} from '@assistant-ui/react-streamdown' +import { code } from '@streamdown/code' +import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react' + +import { PreviewAttachment } from '@/components/chat/preview-attachment' +import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' +import { ZoomableImage } from '@/components/chat/zoomable-image' +import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' +import { createMemoizedMathPlugin } from '@/lib/katex-memo' +import { preprocessMarkdown } from '@/lib/markdown-preprocess' +import { + filePathFromMediaPath, + mediaExternalUrl, + mediaKind, + mediaMime, + mediaName, + mediaPathFromMarkdownHref +} from '@/lib/media' +import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' +import { cn } from '@/lib/utils' + +// Math rendering plugin (KaTeX). Configured once at module scope — the +// plugin is stateless beyond its internal cache so re-creating per-render +// would needlessly thrash. We use a memoizing wrapper around rehype-katex +// (see lib/katex-memo.ts) so that during streaming we re-katex only the +// equations whose source actually changed since the last token. With the +// stock @streamdown/math plugin every equation re-renders on every token, +// which throttles UI updates badly for math-heavy responses; the memoized +// plugin keeps the steady-state work proportional to "new equations +// arriving" rather than "equations × tokens-per-second". +// +// `singleDollarTextMath: true` enables `$x^2$` for inline math (de-facto +// LLM convention). The default false-setting only accepts `$$...$$`. +const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) + +async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> { + const blob = await fetch(dataUrl).then(response => response.blob()) + + return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime })) +} + +async function mediaSrc(path: string): Promise<string> { + if (/^(?:https?|data):/i.test(path)) { + return path + } + + if (!window.hermesDesktop?.readFileDataUrl) { + return mediaExternalUrl(path) + } + + const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) + + return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl +} + +function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) { + return ( + <button + className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground" + onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))} + type="button" + > + Open {kind} file + </button> + ) +} + +function MediaAttachment({ path }: { path: string }) { + const [src, setSrc] = useState('') + const [failed, setFailed] = useState(false) + const kind = mediaKind(path) + const name = mediaName(path) + + useEffect(() => { + let cancelled = false + let objectUrl = '' + + setFailed(false) + setSrc('') + void mediaSrc(path) + .then(value => { + if (value.startsWith('blob:')) { + objectUrl = value + } + + if (!cancelled) { + setSrc(value) + } else if (objectUrl) { + URL.revokeObjectURL(objectUrl) + } + }) + .catch(() => { + if (!cancelled) { + setFailed(true) + } + }) + + return () => { + cancelled = true + + if (objectUrl) { + URL.revokeObjectURL(objectUrl) + } + } + }, [path]) + + if (kind === 'image' && src) { + return ( + <span className="block"> + <MarkdownImage alt={name} src={src} /> + </span> + ) + } + + if (kind === 'audio' && src) { + return ( + <span className="my-3 block max-w-md rounded-xl border border-border bg-muted/35 p-3"> + <span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span> + <audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} /> + {failed && <OpenMediaButton kind="audio" path={path} />} + </span> + ) + } + + if (kind === 'video' && src) { + return ( + <span className="my-3 block max-w-2xl rounded-xl border border-border bg-muted/35 p-3"> + <span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span> + <video + className="block max-h-112 w-full rounded-lg bg-black" + controls + onError={() => setFailed(true)} + src={src} + /> + {failed && <OpenMediaButton kind="video" path={path} />} + </span> + ) + } + + return ( + <a + className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere" + href="#" + onClick={event => { + event.preventDefault() + openExternalLink(mediaExternalUrl(path)) + }} + > + {failed ? `Open ${name}` : `Loading ${name}...`} + </a> + ) +} + +function childrenToText(children: unknown): string { + if (typeof children === 'string' || typeof children === 'number') { + return String(children).trim() + } + + if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) { + return children.join('').trim() + } + + return '' +} + +function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a'>) { + const mediaPath = mediaPathFromMarkdownHref(href) + + if (mediaPath) { + return <MediaAttachment path={mediaPath} /> + } + + const previewTarget = previewTargetFromMarkdownHref(href) + + if (previewTarget) { + return <PreviewAttachment source="explicit-link" target={previewTarget} /> + } + + const target = href ? normalizeExternalUrl(href) : href + + if (!target || !/^https?:\/\//i.test(target)) { + return ( + <a + className={cn( + 'font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere', + className + )} + href={href} + rel="noopener noreferrer" + target="_blank" + {...props} + > + {children} + </a> + ) + } + + const text = childrenToText(children) + const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined + + return ( + <PrettyLink className={cn('wrap-anywhere', className)} fallbackLabel={fallbackLabel} href={target} {...props} /> + ) +} + +function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) { + return ( + <ZoomableImage + alt={alt} + className={cn( + 'm-0 block h-auto w-auto max-h-(--image-preview-height) max-w-[min(100%,var(--image-preview-max-width))] rounded-lg object-contain shadow-[0_0.0625rem_0.125rem_color-mix(in_srgb,#000_4%,transparent),0_0.625rem_1.5rem_color-mix(in_srgb,#000_5%,transparent)]', + className + )} + containerClassName="my-2 block w-fit max-w-full" + slot="aui_markdown-image" + src={src} + {...props} + /> + ) +} + +/** + * Re-publish the active message-part context with React's `useDeferredValue` + * applied to the streaming text and status. The outer wrapper still re-renders + * on every token, but the work it does is trivial (one hook, one provider). + * + * The expensive subtree (Streamdown → micromark → mdast → hast → React) lives + * inside `<TextMessagePartProvider>` and reads the deferred text via the + * normal `useMessagePartText` hook. React's concurrent scheduler then has + * permission to: + * - skip intermediate token states when the next token arrives mid-render + * (it abandons the in-flight deferred render and starts over) + * - deprioritize the markdown render when the main thread is busy with an + * urgent task (typing, scrolling, layout work elsewhere) + * + * Net effect: per-token CPU is unchanged but the *blocking* part of that work + * goes away — typing-while-streaming stays a single-frame paint, scroll + * stutter disappears, and the longtask histogram tightens because long + * commits can be interrupted and discarded. + * + * Industry standard (Streamdown's own block-array setState already uses + * `useTransition`); this just lifts the deferral up to the consumer text + * boundary so it covers the whole pipeline, not just the inner setState. + */ +function DeferStreamingText({ children }: { children: ReactNode }) { + const { text, status } = useMessagePartText() + const deferredText = useDeferredValue(text) + const isRunning = status.type === 'running' + + return ( + <TextMessagePartProvider isRunning={isRunning} text={deferredText}> + {children} + </TextMessagePartProvider> + ) +} + +// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept +// table-driven so adding/tweaking levels is one row. +const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = { + h1: 'text-[1rem] tracking-tight', + h2: 'text-[0.9375rem] tracking-tight', + h3: 'text-[0.875rem]', + h4: 'text-[0.8125rem]' +} + +const MarkdownTextImpl = () => { + const isStreaming = useAuiState(s => s.message.status?.type === 'running') + + // Stable per-state plugin object. The previous inline `{ math: mathPlugin, + // ...(isStreaming ? {} : { code }) }` created a new object identity on every + // render, which churns Streamdown's outer memo + propagates new prop + // identities into every Block. The plugin set really only varies on + // `isStreaming`, so memoize on that. + const plugins = useMemo( + () => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), + [isStreaming] + ) + + const components = useMemo( + () => + ({ + h1: ({ className, ...props }: ComponentProps<'h1'>) => ( + <h1 className={cn('my-1 font-semibold', HEADING_SIZES.h1, className)} {...props} /> + ), + h2: ({ className, ...props }: ComponentProps<'h2'>) => ( + <h2 className={cn('my-1 font-semibold', HEADING_SIZES.h2, className)} {...props} /> + ), + h3: ({ className, ...props }: ComponentProps<'h3'>) => ( + <h3 className={cn('my-1 font-semibold', HEADING_SIZES.h3, className)} {...props} /> + ), + h4: ({ className, ...props }: ComponentProps<'h4'>) => ( + <h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} /> + ), + p: ({ className, ...props }: ComponentProps<'p'>) => ( + <p className={cn('my-1 wrap-anywhere leading-(--dt-line-height)', className)} {...props} /> + ), + a: MarkdownLink, + hr: ({ className, ...props }: ComponentProps<'hr'>) => ( + <hr className={cn('border-border', className)} {...props} /> + ), + blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => ( + <blockquote + className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)} + {...props} + /> + ), + ul: ({ className, ...props }: ComponentProps<'ul'>) => ( + <ul className={cn('my-1 gap-0', className)} {...props} /> + ), + ol: ({ className, ...props }: ComponentProps<'ol'>) => ( + <ol className={cn('my-1 gap-0', className)} {...props} /> + ), + li: ({ className, ...props }: ComponentProps<'li'>) => ( + <li className={cn('leading-(--dt-line-height)', className)} {...props} /> + ), + table: ({ className, ...props }: ComponentProps<'table'>) => ( + <div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border"> + <table + className={cn( + 'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0', + className + )} + {...props} + /> + </div> + ), + thead: ({ className, ...props }: ComponentProps<'thead'>) => ( + <thead className={cn('m-0 bg-muted/35 text-muted-foreground', className)} {...props} /> + ), + th: ({ className, ...props }: ComponentProps<'th'>) => ( + <th + className={cn( + 'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground', + className + )} + {...props} + /> + ), + td: ({ className, ...props }: ComponentProps<'td'>) => ( + <td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} /> + ), + img: MarkdownImage, + SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} /> + }) as StreamdownTextComponents, + [isStreaming] + ) + + return ( + <DeferStreamingText> + <StreamdownTextPrimitive + components={components} + containerClassName={cn( + 'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground', + 'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)', + 'prose-headings:text-foreground prose-strong:text-foreground', + 'prose-a:break-words prose-p:[overflow-wrap:anywhere]', + 'prose-li:marker:text-muted-foreground/70', + 'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none', + '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1' + )} + lineNumbers={false} + mode="streaming" + // Always auto-close incomplete fences — even during streaming. + // Without this, an unclosed ```python ... ``` whose body contains + // `$` (very common: shell snippets, JS template strings, dollar + // amounts) leaks those dollars out to the math parser and they + // get rendered as broken inline math until the closing fence + // arrives. Shiki is independently deferred via `defer={isStreaming}` + // on the SyntaxHighlighter component, so we don't pay code-block + // tokenization on every token even with this set. + parseIncompleteMarkdown + plugins={plugins} + preprocess={preprocessMarkdown} + /> + </DeferStreamingText> + ) +} + +export const MarkdownText = memo(MarkdownTextImpl) diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx new file mode 100644 index 000000000..70f66040e --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx @@ -0,0 +1,500 @@ +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { useEffect, useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { Thread } from './thread' + +const createdAt = new Date('2026-05-01T00:00:00.000Z') + +const resizeObservers = new Set<TestResizeObserver>() + +class TestResizeObserver { + private target: Element | null = null + + constructor(private readonly callback: ResizeObserverCallback) { + resizeObservers.add(this) + } + + observe(target: Element) { + this.target = target + } + + unobserve() {} + + disconnect() { + resizeObservers.delete(this) + } + + trigger(height: number) { + if (!this.target) { + return + } + + this.callback( + [ + { + contentRect: { height } as DOMRectReadOnly, + target: this.target + } as ResizeObserverEntry + ], + this as unknown as ResizeObserver + ) + } +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) + +Element.prototype.scrollTo = function scrollTo() {} + +Element.prototype.animate = function animate() { + return { + cancel: () => {}, + finished: Promise.resolve() + } as unknown as Animation +} + +// jsdom returns 0 for offset*; the virtualizer reads those to size its +// viewport. Fall through to client* (which tests can override) or a sane +// default so virtualized items render. +function stubOffsetDimension( + prop: 'offsetHeight' | 'offsetWidth', + clientProp: 'clientHeight' | 'clientWidth', + fallback: number +) { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) + + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get() { + return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback + } + }) +} + +stubOffsetDimension('offsetWidth', 'clientWidth', 800) +stubOffsetDimension('offsetHeight', 'clientHeight', 600) + +async function wait(ms: number) { + await act(async () => { + await new Promise(resolve => window.setTimeout(resolve, ms)) + }) +} + +function userMessage(): ThreadMessage { + return { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'Stream a response' }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistantMessage(text: string, running = true): ThreadMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text }], + status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function assistantErrorMessage(error: string): ThreadMessage { + return { + id: 'assistant-error-1', + role: 'assistant', + content: [], + status: { type: 'incomplete', reason: 'error', error }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function assistantReasoningMessage(text: string): ThreadMessage { + return { + id: 'assistant-reasoning-1', + role: 'assistant', + content: [{ type: 'reasoning', text }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function assistantMultiReasoningMessage(texts: string[]): ThreadMessage { + return { + id: 'assistant-reasoning-multi-1', + role: 'assistant', + content: texts.map(text => ({ type: 'reasoning', text })), + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function assistantTodoMessage( + todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>, + running = true +): ThreadMessage { + const suffix = todos.map(todo => `${todo.id}:${todo.status}`).join('|') || 'empty' + + return { + id: `assistant-todo-${running ? 'running' : 'done'}-${suffix}`, + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'todo-1', + toolName: 'todo', + args: { todos }, + argsText: JSON.stringify({ todos }), + ...(running ? {} : { result: { todos } }) + } + ], + status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function assistantReasoningTodoMessage( + todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }> +): ThreadMessage { + return { + id: 'assistant-reasoning-todo-1', + role: 'assistant', + content: [ + { type: 'reasoning', text: 'Let me make a quick todo list.' }, + { + type: 'tool-call', + toolCallId: 'todo-1', + toolName: 'todo', + args: { todos }, + argsText: JSON.stringify({ todos }), + result: { todos } + }, + { type: 'text', text: 'Done — fake list created.' } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function StreamingHarness() { + const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()]) + const [isRunning, setIsRunning] = useState(true) + + useEffect(() => { + const first = window.setTimeout(() => { + setMessages([userMessage(), assistantMessage('first chunk')]) + }, 50) + + const second = window.setTimeout(() => { + setMessages([userMessage(), assistantMessage('first chunk second chunk')]) + }, 500) + + const complete = window.setTimeout(() => { + setMessages([userMessage(), assistantMessage('first chunk second chunk', false)]) + setIsRunning(false) + }, 700) + + return () => { + window.clearTimeout(first) + window.clearTimeout(second) + window.clearTimeout(complete) + } + }, []) + + const runtime = useExternalStoreRuntime<ThreadMessage>({ + messages, + isRunning, + onNew: async () => {} + }) + + return ( + <AssistantRuntimeProvider runtime={runtime}> + <Thread loading={isRunning && messages.at(-1)?.role !== 'assistant' ? 'response' : undefined} /> + </AssistantRuntimeProvider> + ) +} + +function TodoHarness({ message }: { message: ThreadMessage }) { + const runtime = useExternalStoreRuntime<ThreadMessage>({ + messages: [message], + isRunning: message.status?.type === 'running', + onNew: async () => {} + }) + + return ( + <AssistantRuntimeProvider runtime={runtime}> + <Thread /> + </AssistantRuntimeProvider> + ) +} + +function MessageHarness({ message }: { message: ThreadMessage }) { + const runtime = useExternalStoreRuntime<ThreadMessage>({ + messages: [message], + isRunning: false, + onNew: async () => {} + }) + + return ( + <AssistantRuntimeProvider runtime={runtime}> + <Thread /> + </AssistantRuntimeProvider> + ) +} + +function ReasoningHarness() { + const runtime = useExternalStoreRuntime<ThreadMessage>({ + messages: [assistantReasoningMessage(' The user is asking what this file is.')], + isRunning: false, + onNew: async () => {} + }) + + return ( + <AssistantRuntimeProvider runtime={runtime}> + <Thread /> + </AssistantRuntimeProvider> + ) +} + +function GroupedReasoningHarness() { + const runtime = useExternalStoreRuntime<ThreadMessage>({ + messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])], + isRunning: false, + onNew: async () => {} + }) + + return ( + <AssistantRuntimeProvider runtime={runtime}> + <Thread /> + </AssistantRuntimeProvider> + ) +} + +function IntroHarness() { + const runtime = useExternalStoreRuntime<ThreadMessage>({ + messages: [], + isRunning: false, + onNew: async () => {} + }) + + return ( + <AssistantRuntimeProvider runtime={runtime}> + <Thread intro={{ personality: 'default', seed: 1 }} /> + </AssistantRuntimeProvider> + ) +} + +describe('assistant-ui streaming renderer', () => { + beforeEach(() => { + resizeObservers.clear() + }) + + it('renders assistant text incrementally before completion', async () => { + const { container } = render(<StreamingHarness />) + + expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy() + + await wait(80) + + await waitFor(() => { + expect(container.textContent).toContain('first chunk') + }) + expect(container.textContent).not.toContain('second chunk') + expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull() + + await wait(500) + + await waitFor(() => { + expect(container.textContent).toContain('first chunk second chunk') + }) + + await wait(250) + + await waitFor(() => { + expect(container.textContent).toContain('first chunk second chunk') + }) + }) + + it('does not render composer clearance for intro-only threads', () => { + const { container } = render(<IntroHarness />) + + expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull() + }) + + it('renders assistant provider errors inline', () => { + render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />) + + expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).') + }) + + it('does not pull the viewport back down after the user scrolls up during streaming', async () => { + const { container } = render(<StreamingHarness />) + + const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement + const viewport = content.parentElement as HTMLDivElement + let scrollHeight = 1_000 + + Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 }) + Object.defineProperty(viewport, 'scrollHeight', { + configurable: true, + get: () => scrollHeight + }) + + await wait(80) + + await act(async () => { + viewport.scrollTop = 800 + fireEvent.scroll(viewport) + }) + await wait(0) + + await act(async () => { + fireEvent.wheel(viewport, { deltaY: -120 }) + viewport.scrollTop = 420 + fireEvent.scroll(viewport) + }) + + scrollHeight = 1_200 + + await act(async () => { + for (const observer of resizeObservers) { + observer.trigger(1_200) + } + }) + await wait(0) + + expect(viewport.scrollTop).toBe(420) + }) + + it('renders reasoning text without a leading token space', () => { + const { container } = render(<ReasoningHarness />) + + fireEvent.click(screen.getByRole('button', { name: /thinking/i })) + + expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe( + 'The user is asking what this file is.' + ) + }) + + it('groups consecutive reasoning parts under one thinking disclosure', () => { + const { container } = render(<GroupedReasoningHarness />) + + const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]') + expect(disclosures.length).toBe(1) + + fireEvent.click(disclosures[0].querySelector('button')!) + + const reasoningParts = container.querySelectorAll('[data-slot="aui_reasoning-text"]') + expect(reasoningParts.length).toBe(2) + expect(reasoningParts[0]?.textContent).toBe('First thought.') + expect(reasoningParts[1]?.textContent).toBe('Second thought.') + }) + + it('renders live todo rows during a running turn', () => { + const { container } = render( + <TodoHarness + message={assistantTodoMessage([ + { content: 'Gather ingredients', id: 'prep', status: 'completed' }, + { content: 'Boil water', id: 'boil', status: 'in_progress' } + ])} + /> + ) + + const ui = within(container) + + expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy() + expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0) + expect(ui.getByText('Gather ingredients')).toBeTruthy() + expect(ui.queryByText(/pending/i)).toBeNull() + expect(ui.queryByRole('button', { name: /todo/i })).toBeNull() + }) + + it('renders archived todos after turn completion regardless of pending state', () => { + const first = render( + <TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} /> + ) + + const ui = within(first.container) + + expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0) + + first.unmount() + + const second = render( + <TodoHarness + message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)} + /> + ) + + const archivedUi = within(second.container) + + expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0) + }) + + it('hoists todo outside the thinking disclosure when reasoning is present', () => { + const { container } = render( + <TodoHarness + message={assistantReasoningTodoMessage([ + { content: 'Buy oats', id: 'oats', status: 'completed' }, + { content: "Reply to Sam's email", id: 'email', status: 'in_progress' } + ])} + /> + ) + + const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]') + const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]') + + expect(todoPanel).toBeTruthy() + expect(thinkingDisclosure).toBeTruthy() + expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx new file mode 100644 index 000000000..2e6bbaf8f --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -0,0 +1,382 @@ +import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react' +import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual' +import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' + +import { cn } from '@/lib/utils' +import { setThreadScrolledUp } from '@/store/thread-scroll' + +const ESTIMATED_ITEM_HEIGHT = 220 +const OVERSCAN = 4 +const AT_BOTTOM_THRESHOLD = 4 + +type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components'] + +type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' } + +interface VirtualizedThreadProps { + clampToComposer: boolean + components: ThreadMessageComponents + emptyPlaceholder?: ReactNode + loadingIndicator?: ReactNode + sessionKey?: string | null +} + +function buildGroups(signature: string): MessageGroup[] { + if (!signature) { + return [] + } + + const messages = signature.split('\n').map(row => { + const [index, id, role] = row.split(':') + + return { id, index: Number(index), role } + }) + + const groups: MessageGroup[] = [] + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + + if (message.role !== 'user') { + groups.push({ id: message.id, index: message.index, kind: 'standalone' }) + + continue + } + + const indices = [message.index] + + while (i + 1 < messages.length && messages[i + 1].role !== 'user') { + indices.push(messages[++i].index) + } + + groups.push({ id: message.id, indices, kind: 'turn' }) + } + + return groups +} + +export const VirtualizedThread: FC<VirtualizedThreadProps> = ({ + clampToComposer, + components, + emptyPlaceholder, + loadingIndicator, + sessionKey +}) => { + const messageSignature = useAuiState(s => + s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') + ) + + const groups = useMemo(() => buildGroups(messageSignature), [messageSignature]) + const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) + const scrollerRef = useRef<HTMLDivElement | null>(null) + + const virtualizer = useVirtualizer({ + count: groups.length, + estimateSize: () => ESTIMATED_ITEM_HEIGHT, + getItemKey: index => groups[index]?.id ?? index, + getScrollElement: () => scrollerRef.current, + // Seed the rect so the initial range mounts something before + // `observeElementRect` reports the real layout (it overrides this). + initialRect: { height: 600, width: 800 }, + overscan: OVERSCAN + }) + + useThreadScrollAnchor({ + enabled: !renderEmpty, + groupCount: groups.length, + scrollerRef, + sessionKey: sessionKey ?? null, + virtualizer + }) + + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + const paddingTop = virtualItems[0]?.start ?? 0 + const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0)) + + return ( + <div + className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]" + style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }} + > + <div + className="size-full overflow-x-hidden overflow-y-auto overscroll-contain" + data-slot="aui_thread-viewport" + ref={scrollerRef} + > + {renderEmpty ? ( + <div + className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8" + data-slot="aui_thread-content" + > + {emptyPlaceholder} + </div> + ) : ( + <div + className={cn( + 'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]' + )} + data-slot="aui_thread-content" + > + {/* Natural-flow virtualization: mounted items render as normal + flex siblings so `position: sticky` on the human bubble + resolves against the scroller without transform interference. + Padding spacers reserve scroll space for unmounted items. */} + <div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}> + {virtualItems.map(virtualItem => { + const group = groups[virtualItem.index] + + if (!group) { + return null + } + + return ( + <div + className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)" + data-index={virtualItem.index} + key={virtualItem.key} + ref={virtualizer.measureElement} + > + {group.kind === 'turn' ? ( + <div + className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)" + data-slot="aui_turn-pair" + > + {group.indices.map(index => ( + <ThreadPrimitive.MessageByIndex components={components} index={index} key={index} /> + ))} + </div> + ) : ( + <ThreadPrimitive.MessageByIndex components={components} index={group.index} /> + )} + </div> + ) + })} + </div> + {loadingIndicator} + {clampToComposer && ( + <div + aria-hidden="true" + className="shrink-0" + data-slot="aui_composer-clearance" + style={{ height: 'var(--thread-last-message-clearance)' }} + /> + )} + </div> + )} + </div> + </div> + ) +} + +interface ScrollAnchorOptions { + enabled: boolean + groupCount: number + scrollerRef: React.RefObject<HTMLDivElement | null> + sessionKey: string | null + virtualizer: Virtualizer<HTMLDivElement, Element> +} + +function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) { + // `armed` = parked at bottom, content growth should follow. Cleared on + // user-driven upward scroll; re-armed when they reach bottom again. + const armedRef = useRef(true) + const lastTopRef = useRef(0) + // Counter that tracks how many scroll events we expect to be ours rather + // than the user's. `pinToBottom` writes `el.scrollTop`, which fires an + // async `scroll` event; without this guard the on-scroll handler can race + // with the programmatic write (because content also grew, the *resulting* + // scrollTop can be lower than `lastTopRef` from the previous frame) and + // misread the programmatic pin as the user scrolling up — which disarms + // sticky-bottom and the user's just-submitted message slides above the + // fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro + // (distFromBottom 0 → 49 within one frame, sticking forever). + const programmaticScrollPendingRef = useRef(0) + const prevSessionKeyRef = useRef(sessionKey) + const prevGroupCountRef = useRef(0) + + const pinToBottom = useCallback(() => { + const el = scrollerRef.current + + if (!el) { + return + } + + // Hold the disarm gate across the scroll event the next line will fire. + programmaticScrollPendingRef.current += 1 + el.scrollTop = el.scrollHeight + lastTopRef.current = el.scrollTop + }, [scrollerRef]) + + const jumpToBottom = useCallback(() => { + armedRef.current = true + + if (groupCount > 0) { + virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' }) + } + + requestAnimationFrame(() => { + if (armedRef.current) { + pinToBottom() + } + }) + }, [groupCount, pinToBottom, virtualizer]) + + useEffect(() => () => setThreadScrolledUp(false), []) + + // Track at-bottom state, dim composer when scrolled up, disarm on user + // scroll/wheel/touch. + useEffect(() => { + const el = scrollerRef.current + + if (!el) { + return undefined + } + + const disarm = () => { + armedRef.current = false + } + + const onScroll = () => { + const top = el.scrollTop + + // If this scroll event is the consequence of `pinToBottom` writing + // `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin + // loop will re-pin on the next frame if the browser clamped us + // short of bottom (because content grew in the same frame). + // Without this guard the post-pin scrollTop gets misread as the + // user scrolling up, disarming sticky-bottom permanently and + // leaving the just-submitted message below the fold. + if (programmaticScrollPendingRef.current > 0) { + programmaticScrollPendingRef.current -= 1 + lastTopRef.current = top + // Always re-arm — sticky-bottom should hold through clamp races. + armedRef.current = true + const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD + setThreadScrolledUp(!atBottom) + + return + } + + if (top + 1 < lastTopRef.current) { + armedRef.current = false + } + + lastTopRef.current = top + + const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD + + if (atBottom) { + armedRef.current = true + } + + setThreadScrolledUp(!atBottom) + } + + const onWheel = (event: WheelEvent) => { + if (event.deltaY < 0) { + disarm() + } + } + + el.addEventListener('scroll', onScroll, { passive: true }) + el.addEventListener('wheel', onWheel, { passive: true }) + el.addEventListener('touchmove', disarm, { passive: true }) + + return () => { + el.removeEventListener('scroll', onScroll) + el.removeEventListener('wheel', onWheel) + el.removeEventListener('touchmove', disarm) + } + }, [scrollerRef]) + + // Follow content growth (streaming, item measurements, loading indicator) + // while armed. During fast streaming the ResizeObserver can fire many + // times per frame as Streamdown re-tokenizes; coalesce to one pin per + // animation frame so we don't run the scroll-event/re-pin chain + // (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per + // token. + useEffect(() => { + if (!enabled) { + return undefined + } + + const el = scrollerRef.current + + if (!el) { + return undefined + } + + let pinRafScheduled = false + const schedulePin = () => { + if (pinRafScheduled || !armedRef.current) { + return + } + pinRafScheduled = true + requestAnimationFrame(() => { + pinRafScheduled = false + if (armedRef.current) { + pinToBottom() + } + }) + } + + const observer = new ResizeObserver(schedulePin) + + observer.observe(el) + + if (el.firstElementChild) { + observer.observe(el.firstElementChild) + } + + return () => observer.disconnect() + }, [enabled, pinToBottom, scrollerRef]) + + // Jump to bottom on session change OR when an empty thread first gets + // content. Both share the same intent and the same effect. + useEffect(() => { + const sessionChanged = prevSessionKeyRef.current !== sessionKey + const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0 + + prevSessionKeyRef.current = sessionKey + prevGroupCountRef.current = groupCount + + if (enabled && (sessionChanged || becameNonEmpty)) { + jumpToBottom() + } + }, [enabled, groupCount, jumpToBottom, sessionKey]) + + // Pre-paint pin: when groupCount increases while armed (optimistic user + // message insert, streaming assistant turn arriving, etc.), pin BEFORE + // the browser commits the layout to screen. Using useLayoutEffect rather + // than useEffect so this runs synchronously after React commits the DOM + // mutation but before the browser paints. Without this, there's a ~50ms + // visual window where the new message sits below the fold while we wait + // for the ResizeObserver / scroll event chain to fire and re-pin. + // + // We pin TWICE in this critical path — once synchronously, then once on + // the next rAF. The second pin catches the case where React mounts the + // new message in the second commit (after our layout effect ran), which + // grows scrollHeight again; without the rAF pin the user briefly sees a + // ~15 px gap below the new message until the RO catches up. Streaming + // tokens use the rate-limited RO path only; only the group-count change + // (which fires once per user submit / new turn arrival) pays for the + // extra pin. + const prevGroupCountForLayoutRef = useRef(groupCount) + useLayoutEffect(() => { + if (!enabled) { + return + } + if (groupCount > prevGroupCountForLayoutRef.current && armedRef.current) { + pinToBottom() + requestAnimationFrame(() => { + if (armedRef.current) { + pinToBottom() + } + }) + } + prevGroupCountForLayoutRef.current = groupCount + }, [enabled, groupCount, pinToBottom]) + + useAuiEvent('thread.runStart', jumpToBottom) +} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx new file mode 100644 index 000000000..0c90d2990 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -0,0 +1,1324 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { + ActionBarPrimitive, + BranchPickerPrimitive, + ComposerPrimitive, + ErrorPrimitive, + MessagePrimitive, + type ToolCallMessagePartProps, + useAui, + useAuiState +} from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { IconPlayerStopFilled } from '@tabler/icons-react' +import { + type ClipboardEvent, + type FC, + type FocusEvent, + type FormEvent, + type KeyboardEvent, + type DragEvent as ReactDragEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' + +import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance' +import { + type ComposerInsertMode, + focusComposerInput, + markActiveComposer, + onComposerFocusRequest, + onComposerInsertRequest +} from '@/app/chat/composer/focus' +import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions' +import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions' +import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs' +import { + composerPlainText, + placeCaretEnd, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from '@/app/chat/composer/rich-editor' +import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils' +import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover' +import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' +import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' +import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text' +import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' +import { MarkdownText } from '@/components/assistant-ui/markdown-text' +import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer' +import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' +import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' +import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' +import { useElapsedSeconds } from '@/components/chat/activity-timer' +import { ActivityTimerText } from '@/components/chat/activity-timer-text' +import { DisclosureRow } from '@/components/chat/disclosure-row' +import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context' +import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder' +import { Intro, type IntroProps } from '@/components/chat/intro' +import { PreviewAttachment } from '@/components/chat/preview-attachment' +import { Codicon } from '@/components/ui/codicon' +import { CopyButton } from '@/components/ui/copy-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Loader } from '@/components/ui/loader' +import type { HermesGateway } from '@/hermes' +import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' +import { triggerHaptic } from '@/lib/haptics' +import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons' +import { extractPreviewTargets } from '@/lib/preview-targets' +import { useEnterAnimation } from '@/lib/use-enter-animation' +import { cn } from '@/lib/utils' +import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' +import { notifyError } from '@/store/notifications' +import { $voicePlayback } from '@/store/voice-playback' + +type ThreadLoadingState = 'response' | 'session' + +interface MessageActionProps { + messageId: string + messageText: string + onBranchInNewChat?: (messageId: string) => void +} + +let readAloudAudio: HTMLAudioElement | null = null + +function partText(part: unknown): string { + if (typeof part === 'string') { + return part + } + + if (!part || typeof part !== 'object') { + return '' + } + + const row = part as { text?: unknown; type?: unknown } + + return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : '' +} + +function messageContentText(content: unknown): string { + if (typeof content === 'string') { + return content.trim() + } + + return Array.isArray(content) ? content.map(partText).join('').trim() : '' +} + +const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i + +const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim()) + +export const Thread: FC<{ + clampToComposer?: boolean + cwd?: string | null + gateway?: HermesGateway | null + intro?: IntroProps + loading?: ThreadLoadingState + onBranchInNewChat?: (messageId: string) => void + onCancel?: () => Promise<void> | void + sessionId?: string | null + sessionKey?: string | null +}> = ({ + clampToComposer = false, + cwd = null, + gateway = null, + intro, + loading, + onBranchInNewChat, + onCancel, + sessionId = null, + sessionKey +}) => { + const messageComponents = useMemo( + () => ({ + AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />, + SystemMessage, + UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />, + UserMessage: () => <UserMessage onCancel={onCancel} /> + }), + [cwd, gateway, onBranchInNewChat, onCancel, sessionId] + ) + + const emptyPlaceholder = intro ? ( + <div + className="flex min-h-0 w-full flex-col items-center justify-center" + style={{ paddingBottom: 'var(--composer-measured-height)' }} + > + <Intro {...intro} /> + </div> + ) : undefined + + return ( + <GeneratedImageProvider> + <div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]"> + <VirtualizedThread + clampToComposer={clampToComposer} + components={messageComponents} + emptyPlaceholder={emptyPlaceholder} + loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null} + sessionKey={sessionKey} + /> + {loading === 'session' && <CenteredThreadSpinner />} + </div> + </GeneratedImageProvider> + ) +} + +function pickPrimaryPreviewTarget(targets: string[]): string[] { + if (targets.length <= 1) { + return targets + } + + const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value)) + + return [localUrl || targets[targets.length - 1]] +} + +const CenteredThreadSpinner: FC = () => ( + <div + aria-label="Loading session" + className="pointer-events-none absolute inset-0 z-1 grid place-items-center" + role="status" + > + <Loader + aria-hidden="true" + className="size-12 text-midground/70" + pathSteps={220} + role="presentation" + strokeScale={0.72} + type="rose-curve" + /> + </div> +) + +const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { + const messageId = useAuiState(s => s.message.id) + const content = useAuiState(s => s.message.content) + const messageText = messageContentText(content) + const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content]) + + const previewTargets = useMemo(() => { + if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) { + return [] + } + + return pickPrimaryPreviewTarget(extractPreviewTargets(messageText)) + }, [messageText]) + + const messageStatus = useAuiState(s => s.message.status?.type) + const isPlaceholder = messageStatus === 'running' && content.length === 0 + const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText]) + + if (isPlaceholder) { + return null + } + + return ( + <MessagePrimitive.Root + className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden" + data-role="assistant" + data-slot="aui_assistant-message-root" + > + <div + className={cn( + 'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground', + interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82' + )} + data-slot="aui_assistant-message-content" + > + {hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />} + <MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} /> + {previewTargets.length > 0 && ( + <div className="mt-3 flex flex-wrap gap-2"> + {previewTargets.map(target => ( + <PreviewAttachment key={target} source="explicit-link" target={target} /> + ))} + </div> + )} + <MessagePrimitive.Error> + <ErrorPrimitive.Root + className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]" + role="alert" + > + <ErrorPrimitive.Message /> + </ErrorPrimitive.Root> + </MessagePrimitive.Error> + </div> + {messageText.trim().length > 0 && !interruptedOnly && ( + <AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} /> + )} + </MessagePrimitive.Root> + ) +} + +const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ + children, + label, + className, + ...rest +}) => ( + <div + aria-label={label} + aria-live="polite" + className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)} + role="status" + {...rest} + > + {children} + </div> +) + +const ResponseLoadingIndicator: FC = () => { + const elapsed = useElapsedSeconds() + + return ( + <StatusRow data-slot="aui_response-loading" label="Hermes is loading a response"> + <span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" /> + <ActivityTimerText seconds={elapsed} /> + </StatusRow> + ) +} + +const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => { + const generatedImage = useGeneratedImageContext() + const running = result === undefined + + useEffect(() => { + generatedImage?.setPending(running) + }, [generatedImage, running]) + + if (!running) { + return null + } + + return ( + <div className="mt-1.5"> + <ImageGenerationPlaceholder /> + </div> + ) +} + +const ChainToolFallback: FC<ToolCallMessagePartProps> = props => { + // todo parts are hoisted to a dedicated panel above the message content. + if (props.toolName === 'todo') { + return null + } + + if (props.toolName === 'image_generate') { + return <ImageGenerateTool {...props} /> + } + + if (props.toolName === 'clarify') { + return <ClarifyTool {...props} /> + } + + return <ToolFallback {...props} /> +} + +const ThinkingDisclosure: FC<{ + children: ReactNode + messageRunning?: boolean + pending?: boolean + timerKey?: string +}> = ({ children, messageRunning = false, pending = false, timerKey }) => { + // `null` = no explicit user toggle yet, defer to the streaming default. + // The default is "auto-open while streaming, auto-collapse when done" so + // reasoning surfaces a live preview without manual interaction. The first + // explicit toggle wins from then on. + const [userOpen, setUserOpen] = useState<boolean | null>(null) + const elapsed = useElapsedSeconds(pending, timerKey) + const scrollRef = useRef<HTMLDivElement | null>(null) + const contentRef = useRef<HTMLDivElement | null>(null) + const enterRef = useEnterAnimation(messageRunning, timerKey) + + const open = userOpen ?? pending + const isPreview = pending && userOpen === null + + // While the preview is live, pin the scroll container to the bottom on + // every content growth so the latest tokens are always visible. Combined + // with the top mask in styles.css, this reads as text settling in from + // below while older lines fade out at the top. + useEffect(() => { + if (!isPreview) { + return + } + + const el = scrollRef.current + const content = contentRef.current + + if (!el || !content) { + return + } + + const pin = () => { + el.scrollTop = el.scrollHeight + } + + pin() + const observer = new ResizeObserver(pin) + observer.observe(content) + + return () => observer.disconnect() + }, [isPreview]) + + return ( + <div + className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)" + data-slot="aui_thinking-disclosure" + ref={enterRef} + > + <DisclosureRow onToggle={() => setUserOpen(!open)} open={open}> + <span className="flex min-w-0 items-baseline gap-1.5"> + <span + className={cn( + 'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)', + pending && 'shimmer text-foreground/55' + )} + > + Thinking + </span> + {pending && ( + <ActivityTimerText + className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)" + seconds={elapsed} + /> + )} + </span> + </DisclosureRow> + {open && ( + <div + className={cn( + // Body sits flush with the "Thinking" header — no left indent — + // and inherits the disclosure-level opacity fade defined in + // styles.css (~0.67 at rest, 1 on hover/focus). + 'mt-0.5 w-full min-w-0 max-w-full overflow-hidden wrap-anywhere pb-1', + isPreview && 'thinking-preview max-h-40' + )} + ref={scrollRef} + > + <div ref={contentRef}>{children}</div> + </div> + )} + </div> + ) +} + +// Self-gate "Thinking…" on this message's own reasoning parts. Reading +// `thread.isRunning` directly would flicker shimmer/timer on every old +// assistant whenever the external-store runtime clears+reimports its +// repository (one ref-identity bump per streaming delta). +const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ + children, + endIndex, + startIndex +}) => { + const messageId = useAuiState(s => s.message.id) + const messageRunning = useAuiState(s => s.message.status?.type === 'running') + + const pending = useAuiState( + s => + s.thread.isRunning && + s.message.status?.type === 'running' && + s.message.parts + .slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex)) + .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') + ) + + return ( + <ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}> + {children} + </ThinkingDisclosure> + ) +} + +const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { + const displayText = text.trimStart() + + return ( + <div + className={cn( + 'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85', + status?.type === 'running' && 'shimmer text-muted-foreground/55' + )} + data-slot="aui_reasoning-text" + > + {displayText} + </div> + ) +} + +// Module-level constant so the `components` prop on `MessagePrimitive.Parts` +// has a stable identity across renders. Without this every AssistantMessage +// render would create a fresh `components` object, invalidating the memo on +// `MessagePrimitivePartByIndex` and forcing every tool/reasoning child to +// re-render on every streaming delta. Memo invalidation alone doesn't +// remount, but combined with the previous ToolFallback group-swap it was a +// big chunk of the per-delta work. +const MESSAGE_PARTS_COMPONENTS = { + Reasoning: ReasoningTextPart, + ReasoningGroup: ReasoningAccordionGroup, + Text: MarkdownText, + ToolGroup: ToolGroupSlot, + tools: { Fallback: ChainToolFallback } +} as const + +const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) + +const SHORT_FMT = new Intl.DateTimeFormat(undefined, { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'short' +}) + +function startOfDay(d: Date): number { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() +} + +function formatMessageTimestamp(value: Date | string | number | undefined): string { + if (!value) { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + + if (Number.isNaN(date.getTime())) { + return '' + } + + const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000) + + if (dayDelta === 0) { + return `Today, ${TIME_FMT.format(date)}` + } + + if (dayDelta === 1) { + return `Yesterday, ${TIME_FMT.format(date)}` + } + + return SHORT_FMT.format(date) +} + +const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => { + const [menuOpen, setMenuOpen] = useState(false) + + return ( + <div className="relative flex w-full shrink-0 justify-end"> + <ActionBarPrimitive.Root + className={cn( + // NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the + // bar while the thread streams, which collapses every completed + // assistant message's footer by this bar's height and shifts the + // whole conversation when the turn resolves. The bar is already + // invisible by default (opacity-0 + pointer-events-none, reveals on + // hover), so keeping it mounted reserves stable layout height with + // no visual change during streaming. + 'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100', + menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100' + )} + data-slot="aui_msg-actions" + > + <CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} /> + <ActionBarPrimitive.Reload asChild> + <TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh"> + <Codicon name="refresh" /> + </TooltipIconButton> + </ActionBarPrimitive.Reload> + <DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}> + <DropdownMenuTrigger asChild> + <TooltipIconButton tooltip="More actions"> + <Codicon name="ellipsis" /> + </TooltipIconButton> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}> + <MessageTimestamp /> + <DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}> + <GitBranchIcon /> + Branch in new chat + </DropdownMenuItem> + <ReadAloudItem messageId={messageId} text={messageText} /> + </DropdownMenuContent> + </DropdownMenu> + </ActionBarPrimitive.Root> + </div> + ) +} + +const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => { + const voicePlayback = useStore($voicePlayback) + + const readAloudStatus = + voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle' + + const isPreparing = readAloudStatus === 'preparing' + const isSpeaking = readAloudStatus === 'speaking' + const anyPlaybackActive = voicePlayback.status !== 'idle' + const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon + + const read = useCallback(async () => { + if (!text || $voicePlayback.get().status !== 'idle') { + return + } + + try { + await playSpeechText(text, { messageId, source: 'read-aloud' }) + } catch (error) { + notifyError(error, 'Read aloud failed') + } + }, [messageId, text]) + + return ( + <DropdownMenuItem + disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))} + onSelect={e => { + e.preventDefault() + void (isSpeaking ? stopVoicePlayback() : read()) + }} + > + <Icon className={isPreparing ? 'animate-spin' : undefined} /> + {isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'} + </DropdownMenuItem> + ) +} + +const MessageTimestamp: FC = () => { + const createdAt = useAuiState(s => s.message.createdAt) + const label = formatMessageTimestamp(createdAt) + + if (!label) { + return null + } + + return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel> +} + +const AssistantFooter: FC<MessageActionProps> = props => ( + <div className="flex min-h-6 flex-col items-end gap-1 pr-(--message-text-indent) pl-(--message-text-indent)"> + <BranchPickerPrimitive.Root + className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground" + hideWhenSingleBranch + > + <BranchPickerPrimitive.Previous className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35"> + <Codicon name="chevron-left" size="0.875rem" /> + </BranchPickerPrimitive.Previous> + <span className="tabular-nums"> + <BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count /> + </span> + <BranchPickerPrimitive.Next className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35"> + <Codicon name="chevron-right" size="0.875rem" /> + </BranchPickerPrimitive.Next> + </BranchPickerPrimitive.Root> + <AssistantActionBar {...props} /> + </div> +) + +const EMPTY_ATTACHMENT_REFS: string[] = [] + +function messageAttachmentRefs(value: unknown): string[] { + if (!Array.isArray(value)) { + return EMPTY_ATTACHMENT_REFS + } + + return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS +} + +function StickyHumanMessageContainer({ children }: { children: ReactNode }) { + return ( + <div + className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2" + data-role="user" + data-slot="aui_user-message-root" + > + {children} + </div> + ) +} + +// Shared "user bubble" base. Both the read-only message and the inline +// edit composer render the same bubble surface (rounded glass card, +// shadow-composer); they only differ in border weight, cursor, and +// padding-right (the read-only view reserves room for the restore icon). +const USER_BUBBLE_BASE_CLASS = + 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer' + +const USER_ACTION_ICON_BUTTON_CLASS = + 'grid cursor-pointer place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' + +const USER_ACTION_ICON_SIZE = '0.6875rem' +const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" /> + +const UserMessage: FC<{ + onCancel?: () => Promise<void> | void +}> = ({ onCancel }) => { + const messageId = useAuiState(s => s.message.id) + const content = useAuiState(s => s.message.content) + const messageText = messageContentText(content) + const threadRunning = useAuiState(s => s.thread.isRunning) + + const latestUserId = useAuiState(s => { + for (let i = s.thread.messages.length - 1; i >= 0; i--) { + const message = s.thread.messages[i] as { id?: string; role?: string } + + if (message.role === 'user') { + return message.id ?? null + } + } + + return null + }) + + const attachmentRefs = useAuiState(s => { + const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown } + + return messageAttachmentRefs(custom.attachmentRefs) + }) + + const hasBody = messageText.trim().length > 0 + const isLatestUser = messageId === latestUserId + const showStop = isLatestUser && threadRunning && Boolean(onCancel) + const showRestore = !isLatestUser && !threadRunning + + const bubbleClassName = cn( + USER_BUBBLE_BASE_CLASS, + 'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors', + !threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)' + ) + + const bubbleContent = ( + <> + {attachmentRefs.length > 0 && ( + <span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5"> + <DirectiveContent text={attachmentRefs.join(' ')} /> + </span> + )} + {hasBody && ( + <span className="wrap-anywhere block whitespace-pre-line"> + <MessagePrimitive.Parts components={{ Text: DirectiveText }} /> + </span> + )} + </> + ) + + return ( + <MessagePrimitive.Root asChild> + <StickyHumanMessageContainer> + <ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions"> + <div className="human-message-with-todos-wrapper flex w-full flex-col gap-0"> + <div className="relative w-full"> + {threadRunning ? ( + <div className={bubbleClassName}>{bubbleContent}</div> + ) : ( + <ActionBarPrimitive.Edit asChild> + <button + aria-label="Edit message" + className={bubbleClassName} + onClick={() => triggerHaptic('selection')} + title="Edit message" + type="button" + > + {bubbleContent} + </button> + </ActionBarPrimitive.Edit> + )} + {(showStop || showRestore) && ( + <div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100"> + {showStop ? ( + <button + aria-label="Stop" + className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)} + onClick={event => { + event.preventDefault() + event.stopPropagation() + void onCancel?.() + }} + title="Stop" + type="button" + > + {StopGlyph} + </button> + ) : ( + <span + aria-hidden="true" + className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)" + title="Editable checkpoint" + > + <Codicon name="discard" size="0.875rem" /> + </span> + )} + </div> + )} + </div> + <BranchPickerPrimitive.Root + className="checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)" + hideWhenSingleBranch + > + <span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" /> + <BranchPickerPrimitive.Previous + className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default" + title="Restore previous checkpoint" + > + Restore checkpoint + </BranchPickerPrimitive.Previous> + <span className="checkpoint-divider opacity-55"> + <BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count /> + </span> + <BranchPickerPrimitive.Next + className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default" + title="Restore next checkpoint" + > + Go forward + </BranchPickerPrimitive.Next> + </BranchPickerPrimitive.Root> + </div> + </ActionBarPrimitive.Root> + </StickyHumanMessageContainer> + </MessagePrimitive.Root> + ) +} + +const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/ + +const SystemMessage: FC = () => { + const text = useAuiState(s => messageContentText(s.message.content)) + + if (!text) { + return null + } + + const slashStatus = text.match(SLASH_STATUS_RE) + + if (slashStatus?.groups) { + return ( + <MessagePrimitive.Root + className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60" + data-role="system" + data-slot="aui_system-message-root" + > + <span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span> + <span className="mx-1.5 text-muted-foreground/35">·</span> + <span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span> + </MessagePrimitive.Root> + ) + } + + return ( + <MessagePrimitive.Root + className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55" + data-role="system" + data-slot="aui_system-message-root" + > + <span className="whitespace-pre-wrap">{text}</span> + </MessagePrimitive.Root> + ) +} + +interface UserEditComposerProps { + cwd: string | null + gateway: HermesGateway | null + sessionId: string | null +} + +const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => { + const aui = useAui() + const draft = useAuiState(s => s.composer.text) + const rootRef = useRef<HTMLDivElement | null>(null) + const editorRef = useRef<HTMLDivElement | null>(null) + const draftRef = useRef(draft) + const dragDepthRef = useRef(0) + const [dragActive, setDragActive] = useState(false) + const [trigger, setTrigger] = useState<TriggerState | null>(null) + const [triggerActive, setTriggerActive] = useState(0) + const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([]) + const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top') + const [focusRequestId, setFocusRequestId] = useState(0) + const [submitting, setSubmitting] = useState(false) + const expanded = draft.includes('\n') + const canSubmit = draft.trim().length > 0 + const at = useAtCompletions({ cwd, gateway, sessionId }) + const slash = useSlashCompletions({ gateway }) + + const focusEditor = useCallback(() => { + const editor = editorRef.current + + focusComposerInput(editor) + + if (editor) { + placeCaretEnd(editor) + } + + markActiveComposer('edit') + }, []) + + const requestEditFocus = useCallback(() => { + setFocusRequestId(id => id + 1) + }, []) + + const appendExternalText = useCallback( + (text: string, mode: ComposerInsertMode) => { + const value = text.trim() + + if (!value) { + return + } + + const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current + const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' + const next = `${base}${sep}${value}` + + draftRef.current = next + aui.composer().setText(next) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, next) + placeCaretEnd(editor) + } + + setFocusRequestId(id => id + 1) + }, + [aui] + ) + + useEffect(() => { + draftRef.current = draft + + const editor = editorRef.current + + if ( + editor && + (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft)) + ) { + renderComposerContents(editor, draft) + + if (document.activeElement === editor) { + placeCaretEnd(editor) + } + } + }, [draft]) + + useEffect(() => { + focusEditor() + }, [focusEditor, focusRequestId]) + + useEffect(() => { + const offFocus = onComposerFocusRequest(target => { + if (target === 'edit') { + setFocusRequestId(id => id + 1) + } + }) + + const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + if (target === 'edit') { + appendExternalText(text, mode) + } + }) + + return () => { + offFocus() + offInsert() + } + }, [appendExternalText]) + + const syncDraftFromEditor = useCallback( + (editor: HTMLDivElement) => { + const nextDraft = composerPlainText(editor) + + if (nextDraft !== draftRef.current) { + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + } + + return nextDraft + }, + [aui] + ) + + const refreshTrigger = useCallback(() => { + const editor = editorRef.current + + if (!editor) { + return + } + + const before = textBeforeCaret(editor) + const detected = detectTrigger(before ?? composerPlainText(editor)) + + if (detected) { + const rect = editor.getBoundingClientRect() + const spaceAbove = rect.top + const spaceBelow = window.innerHeight - rect.bottom + + setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top') + } + + setTrigger(detected) + setTriggerActive(0) + }, []) + + const closeTrigger = useCallback(() => { + setTrigger(null) + setTriggerItems([]) + setTriggerActive(0) + }, []) + + const triggerAdapter: Unstable_TriggerAdapter | null = + trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null + + useEffect(() => { + if (!trigger || !triggerAdapter?.search) { + setTriggerItems([]) + + return + } + + setTriggerItems(triggerAdapter.search(trigger.query)) + }, [trigger, triggerAdapter]) + + useEffect(() => { + setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1))) + }, [triggerItems.length]) + + const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false + + const replaceTriggerWithChip = useCallback( + (item: Unstable_TriggerItem) => { + const editor = editorRef.current + + if (!editor || !trigger) { + return + } + + const serialized = hermesDirectiveFormatter.serialize(item) + const starter = serialized.endsWith(':') + const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` + const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) + + const finish = () => { + draftRef.current = composerPlainText(editor) + aui.composer().setText(draftRef.current) + requestEditFocus() + starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger() + } + + const sel = window.getSelection() + const range = sel?.rangeCount ? sel.getRangeAt(0) : null + const node = range?.startContainer + const offset = range?.startOffset ?? 0 + + if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) { + const current = composerPlainText(editor) + renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`) + placeCaretEnd(editor) + + return finish() + } + + const replaceRange = document.createRange() + replaceRange.setStart(node, offset - trigger.tokenLength) + replaceRange.setEnd(node, offset) + replaceRange.deleteContents() + + if (directive) { + const chip = refChipElement(directive[1], directive[2]) + const space = document.createTextNode(' ') + const fragment = document.createDocumentFragment() + fragment.append(chip, space) + replaceRange.insertNode(fragment) + + const caret = document.createRange() + caret.setStart(space, 1) + caret.collapse(true) + sel.removeAllRanges() + sel.addRange(caret) + + return finish() + } + + document.execCommand('insertText', false, text) + finish() + }, + [aui, closeTrigger, refreshTrigger, requestEditFocus, trigger] + ) + + const insertDroppedRefs = useCallback( + (candidates: ReturnType<typeof extractDroppedFiles>) => { + const editor = editorRef.current + + if (!editor) { + return false + } + + const refs = candidates + .map(candidate => droppedFileInlineRef(candidate, cwd)) + .filter((ref): ref is string => Boolean(ref)) + + const nextDraft = insertInlineRefsIntoEditor(editor, refs) + + if (nextDraft === null) { + return false + } + + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + requestEditFocus() + + return true + }, + [aui, cwd, requestEditFocus] + ) + + const resetDragState = useCallback(() => { + dragDepthRef.current = 0 + setDragActive(false) + }, []) + + const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + dragDepthRef.current += 1 + + if (!dragActive) { + setDragActive(true) + } + } + + const handleDragOver = (event: ReactDragEvent<HTMLElement>) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => { + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + + if (dragDepthRef.current === 0) { + setDragActive(false) + } + } + + const handleDrop = (event: ReactDragEvent<HTMLElement>) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (!candidates.length) { + return + } + + event.preventDefault() + event.stopPropagation() + resetDragState() + + if (insertDroppedRefs(candidates)) { + triggerHaptic('selection') + } + } + + const handleInput = (event: FormEvent<HTMLDivElement>) => { + const editor = event.currentTarget + + if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { + editor.replaceChildren() + } + + syncDraftFromEditor(editor) + window.setTimeout(refreshTrigger, 0) + } + + const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => { + const pastedText = event.clipboardData.getData('text') + + if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) { + event.preventDefault() + + return + } + + event.preventDefault() + document.execCommand('insertText', false, pastedText) + syncDraftFromEditor(event.currentTarget) + } + + const submitEdit = (editor: HTMLDivElement) => { + const nextDraft = syncDraftFromEditor(editor) + + if (submitting || !nextDraft.trim()) { + return + } + + setSubmitting(true) + aui.composer().send() + } + + const handleEditBlur = useCallback( + (event: FocusEvent<HTMLDivElement>) => { + const nextTarget = event.relatedTarget + + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return + } + + window.setTimeout(() => { + const root = rootRef.current + const active = document.activeElement + + if (submitting || (root && active && root.contains(active))) { + return + } + + closeTrigger() + aui.composer().cancel() + }, 80) + }, + [aui, closeTrigger, submitting] + ) + + const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { + if (trigger && triggerItems.length > 0) { + if (event.key === 'ArrowDown') { + event.preventDefault() + setTriggerActive(idx => (idx + 1) % triggerItems.length) + + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) + + return + } + + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault() + const item = triggerItems[triggerActive] + + if (item) { + replaceTriggerWithChip(item) + } + + return + } + + if (event.key === 'Escape') { + event.preventDefault() + closeTrigger() + + return + } + } + + if (event.key === 'Escape') { + event.preventDefault() + aui.composer().cancel() + + return + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + submitEdit(event.currentTarget) + } + } + + return ( + <ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root"> + <StickyHumanMessageContainer> + <div + className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)" + onBlur={handleEditBlur} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + ref={rootRef} + > + {trigger && ( + <ComposerTriggerPopover + activeIndex={triggerActive} + items={triggerItems} + kind={trigger.kind} + loading={triggerLoading} + onHover={setTriggerActive} + onPick={replaceTriggerWithChip} + placement={triggerPlacement} + /> + )} + <div + className={cn( + USER_BUBBLE_BASE_CLASS, + 'ui-prompt-input__container relative border-(--ui-stroke-secondary) data-[expanded=true]:min-h-20', + COMPOSER_DROP_FADE_CLASS, + dragActive && COMPOSER_DROP_ACTIVE_CLASS + )} + data-expanded={expanded ? 'true' : undefined} + > + <div + aria-label="Edit message" + autoFocus + className={cn( + 'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none', + 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', + '**:data-ref-text:cursor-default', + expanded ? 'min-h-16' : 'min-h-[1.25rem]' + )} + contentEditable + data-placeholder="Edit message" + data-slot={RICH_INPUT_SLOT} + onBlur={() => window.setTimeout(closeTrigger, 80)} + onDragOver={handleDragOver} + onDrop={handleDrop} + onFocus={() => markActiveComposer('edit')} + onInput={handleInput} + onKeyDown={handleKeyDown} + onKeyUp={() => window.setTimeout(refreshTrigger, 0)} + onMouseUp={refreshTrigger} + onPaste={handlePaste} + ref={editorRef} + role="textbox" + suppressContentEditableWarning + /> + <ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} /> + <button + aria-label="Send edited message" + className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)} + disabled={!canSubmit || submitting} + onClick={() => { + const editor = editorRef.current + + if (editor) { + submitEdit(editor) + } + }} + title="Send edited message" + type="button" + > + {submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />} + </button> + </div> + </div> + </StickyHumanMessageContainer> + </ComposerPrimitive.Root> + ) +} diff --git a/apps/desktop/src/components/assistant-ui/todo-tool.tsx b/apps/desktop/src/components/assistant-ui/todo-tool.tsx new file mode 100644 index 000000000..549c8c3bd --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/todo-tool.tsx @@ -0,0 +1,109 @@ +import { type FC } from 'react' + +import { Checkbox } from '@/components/ui/checkbox' +import { Loader2Icon } from '@/lib/icons' +import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos' +import { cn } from '@/lib/utils' + +export function todosFromMessageContent(content: unknown): TodoItem[] { + if (!Array.isArray(content)) { + return [] + } + + let latest: null | TodoItem[] = null + + for (const part of content) { + if (!part || typeof part !== 'object') { + continue + } + + const row = part as Record<string, unknown> + + if (row.type !== 'tool-call' || row.toolName !== 'todo') { + continue + } + + const parsed = parseTodos(row.result) ?? parseTodos(row.args) + + if (parsed !== null) { + latest = parsed + } + } + + return latest ?? [] +} + +const headerLabel = (todos: readonly TodoItem[]): string => + todos.find(t => t.status === 'in_progress')?.content ?? + todos.find(t => t.status === 'pending')?.content ?? + todos.at(-1)?.content ?? + 'Tasks' + +const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => { + if (status === 'in_progress') { + return ( + <span + aria-label={`In progress: ${label}`} + className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]" + > + <Loader2Icon className="size-3 animate-spin text-ring" /> + </span> + ) + } + + const checked = status === 'completed' + + return ( + <Checkbox + aria-label={label} + checked={checked} + className={cn( + 'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100', + checked && + 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3', + status === 'cancelled' && 'border-muted-foreground/40' + )} + disabled + /> + ) +} + +export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { + if (!todos.length) { + return null + } + + const label = headerLabel(todos) + + return ( + <section + className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]" + data-slot="aui_todo-hoisted" + > + <header className="px-3 pt-3 pb-2"> + <span + className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground" + title={label} + > + {label} + </span> + </header> + <ul className="grid min-w-0 gap-0.5 px-3 pb-3"> + {todos.map(todo => ( + <li + // Active row at full presence; everything else fades. Opacity on + // the row so the checkbox glyph dims with the text. + className={cn( + 'flex min-w-0 items-center gap-3 py-1.5 transition-opacity', + todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45' + )} + key={todo.id} + > + <Checkmark label={todo.content} status={todo.status} /> + <span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span> + </li> + ))} + </ul> + </section> + ) +} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts new file mode 100644 index 000000000..9ca808d20 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -0,0 +1,1345 @@ +import { normalizeExternalUrl } from '@/lib/external-link' +import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' + +export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' +export type ToolStatus = 'error' | 'running' | 'success' | 'warning' + +export interface ToolPart { + args?: unknown + isError?: boolean + result?: unknown + toolCallId?: string + toolName: string + type: 'tool-call' +} + +export interface SearchResultRow { + snippet: string + title: string + url: string +} + +interface CountMetric { + count: number + noun: string +} + +export interface ToolView { + countLabel?: string + detail: string + detailLabel: string + durationLabel?: string + icon?: string + imageUrl?: string + inlineDiff: string + previewTarget?: string + rawArgs: string + rawResult: string + searchHits?: SearchResultRow[] + status: ToolStatus + subtitle: string + title: string + tone: ToolTone +} + +interface ToolMeta { + done: string + icon?: string + pending: string + tone: ToolTone +} + +export interface MessageRunningStateSlice { + message: { + status?: { + type?: string + } + } + thread: { + isRunning: boolean + } +} + +const TOOL_META: Record<string, ToolMeta> = { + browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' }, + browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' }, + browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' }, + browser_snapshot: { + done: 'Captured page snapshot', + pending: 'Capturing page snapshot', + icon: 'globe', + tone: 'browser' + }, + browser_take_screenshot: { + done: 'Captured screenshot', + pending: 'Capturing screenshot', + icon: 'file-media', + tone: 'browser' + }, + browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' }, + edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }, + execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' }, + image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' }, + list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' }, + read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' }, + search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' }, + session_search_recall: { + done: 'Searched session history', + pending: 'Searching session history', + icon: 'search', + tone: 'agent' + }, + terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' }, + todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' }, + web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' }, + web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' }, + write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' } +} + +const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g +const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu +const BACKTICK_NOISE_RE = /`{3,}/g + +export const selectMessageRunning = (state: MessageRunningStateSlice) => + state.thread.isRunning && state.message.status?.type === 'running' + +function titleForTool(name: string): string { + const normalized = name.replace(/^browser_/, '').replace(/^web_/, '') + + return ( + normalized + .split('_') + .filter(Boolean) + .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join(' ') || name + ) +} + +const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [ + { prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' }, + { prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' } +] + +function toolMeta(name: string): ToolMeta { + if (TOOL_META[name]) { + return TOOL_META[name] + } + + const action = titleForTool(name) + const prefix = PREFIX_META.find(p => name.startsWith(p.prefix)) + + return prefix + ? { + done: `${prefix.verb} ${action}`, + pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`, + icon: prefix.icon, + tone: prefix.tone + } + : { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +export function compactPreview(value: unknown, max = 72): string { + let raw: unknown + + if (typeof value === 'string') { + raw = value + } else { + raw = parseMaybeObject(value).context + } + + if (typeof raw !== 'string') { + if (raw == null) { + raw = '' + } else { + try { + raw = JSON.stringify(raw) + } catch { + raw = String(raw) + } + } + } + + const line = (raw as string).replace(/\s+/g, ' ').trim() + + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} + +function contextValue(value: unknown): string { + const row = parseMaybeObject(value) + + if (typeof row.context === 'string') { + return row.context + } + + if (typeof row.preview === 'string') { + return row.preview + } + + return typeof value === 'string' ? value : '' +} + +function prettyJson(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2) +} + +function parseMaybeObject(value: unknown): Record<string, unknown> { + if (isRecord(value)) { + return value + } + + if (typeof value !== 'string' || !value.trim()) { + return {} + } + + try { + const parsed = JSON.parse(value) + + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +function unwrapToolPayload(value: unknown): unknown { + const record = parseMaybeObject(value) + + for (const key of ['data', 'result', 'output', 'response', 'payload']) { + const payload = record[key] + + if (payload !== undefined && payload !== null) { + return payload + } + } + + return value +} + +function numberValue(value: unknown): null | number { + const n = typeof value === 'number' ? value : Number(value) + + return Number.isFinite(n) ? n : null +} + +function formatDurationSeconds(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '' + } + + if (seconds < 1) { + const ms = Math.max(1, Math.round(seconds * 1000)) + + return `${ms}ms` + } + + if (seconds < 60) { + return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s` + } + + const wholeSeconds = Math.round(seconds) + const minutes = Math.floor(wholeSeconds / 60) + const remSeconds = wholeSeconds % 60 + + if (minutes < 60) { + return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m` + } + + const hours = Math.floor(minutes / 60) + const remMinutes = minutes % 60 + + return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h` +} + +const COUNT_FIELD_KEYS = [ + 'count', + 'total', + 'result_count', + 'results_count', + 'num_results', + 'match_count', + 'matches_count', + 'file_count', + 'files_count', + 'item_count', + 'items_count', + 'search_count', + 'searches_count', + 'source_count', + 'sources_count', + 'document_count', + 'documents_count', + 'updated', + 'added', + 'removed', + 'deleted', + 'created', + 'changed', + 'processed', + 'steps' +] as const + +const COUNT_ARRAY_KEYS = ['results', 'items', 'matches', 'files', 'documents', 'sources', 'rows'] as const + +const COUNT_EXCLUDED_KEYS = new Set(['duration_s', 'exit_code', 'status_code']) + +const COUNT_NOUN_BY_FIELD: Partial<Record<(typeof COUNT_FIELD_KEYS)[number], string>> = { + count: '', + total: '', + result_count: 'result', + results_count: 'result', + num_results: 'result', + match_count: 'match', + matches_count: 'match', + file_count: 'file', + files_count: 'file', + item_count: 'item', + items_count: 'item', + search_count: 'search', + searches_count: 'search', + source_count: 'source', + sources_count: 'source', + document_count: 'document', + documents_count: 'document', + updated: 'item', + added: 'item', + removed: 'item', + deleted: 'item', + created: 'item', + changed: 'item', + processed: 'item', + steps: 'step' +} + +const COUNT_NOUN_BY_ARRAY: Record<(typeof COUNT_ARRAY_KEYS)[number], string> = { + documents: 'document', + files: 'file', + items: 'item', + matches: 'match', + results: 'result', + rows: 'row', + sources: 'source' +} + +const DEFAULT_COUNT_NOUN_BY_TOOL: Record<string, string> = { + browser_snapshot: 'item', + list_files: 'file', + search_files: 'result', + session_search_recall: 'result', + todo: 'todo', + web_search: 'result' +} + +function countFromUnknown(value: unknown): null | number { + if (Array.isArray(value)) { + return value.length > 0 ? value.length : null + } + + const n = numberValue(value) + + if (n === null || n <= 0) { + return null + } + + return Math.round(n) +} + +function singularizeNoun(noun: string): string { + const normalized = noun.trim().toLowerCase() + + if (!normalized) { + return '' + } + + if (normalized.endsWith('ies') && normalized.length > 3) { + return `${normalized.slice(0, -3)}y` + } + + if (/(xes|zes|ches|shes|sses)$/.test(normalized) && normalized.length > 3) { + return normalized.slice(0, -2) + } + + if (normalized.endsWith('s') && normalized.length > 2 && !normalized.endsWith('ss')) { + return normalized.slice(0, -1) + } + + return normalized +} + +function pluralizeNoun(noun: string, count: number): string { + if (count === 1) { + return noun + } + + if (noun === 'search') { + return 'searches' + } + + if (noun.endsWith('y') && noun.length > 1 && !/[aeiou]y$/i.test(noun)) { + return `${noun.slice(0, -1)}ies` + } + + if (/(s|x|z|ch|sh)$/i.test(noun)) { + return `${noun}es` + } + + return `${noun}s` +} + +function formatCountLabel(metric: CountMetric): string { + return `${metric.count} ${pluralizeNoun(metric.noun, metric.count)}` +} + +function countMetric(count: number, noun: string): CountMetric { + return { count, noun: singularizeNoun(noun) || 'item' } +} + +function normalizeMetricForTool(toolName: string, metric: CountMetric): CountMetric { + if (toolName === 'web_search') { + return countMetric(metric.count, 'result') + } + + return metric +} + +function fallbackCountNoun(toolName: string): string { + return DEFAULT_COUNT_NOUN_BY_TOOL[toolName] || 'item' +} + +function dynamicCountNounFromKey(key: string, fallbackNoun: string): string { + const normalized = key.toLowerCase() + + if (normalized === 'count' || normalized === 'total') { + return fallbackNoun + } + + const stripped = normalized.replace(/_(count|total)$/i, '').replace(/^num_/, '') + + return singularizeNoun(stripped) || fallbackNoun +} + +function countFromRecord(record: Record<string, unknown>, fallbackNoun: string): CountMetric | null { + for (const key of COUNT_FIELD_KEYS) { + const value = record[key] + const count = countFromUnknown(value) + + if (count !== null) { + return countMetric(count, COUNT_NOUN_BY_FIELD[key] || fallbackNoun) + } + } + + for (const key of COUNT_ARRAY_KEYS) { + const value = record[key] + const count = countFromUnknown(value) + + if (count !== null) { + return countMetric(count, COUNT_NOUN_BY_ARRAY[key] || fallbackNoun) + } + } + + for (const [key, value] of Object.entries(record)) { + if (COUNT_EXCLUDED_KEYS.has(key)) { + continue + } + + if (!/_count$|_total$/i.test(key)) { + continue + } + + const count = countFromUnknown(value) + + if (count !== null) { + return countMetric(count, dynamicCountNounFromKey(key, fallbackNoun)) + } + } + + return null +} + +function countFromText(value: string, fallbackNoun: string): CountMetric | null { + const text = value.trim() + + if (!text) { + return null + } + + const unitMatch = + text.match(/\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b/i) || + text.match(/\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b/i) + + if (unitMatch?.[1]) { + const n = Number(unitMatch[1]) + const noun = unitMatch[2] ? singularizeNoun(unitMatch[2]) : fallbackNoun + + return Number.isFinite(n) && n > 0 ? countMetric(Math.round(n), noun) : null + } + + return null +} + +function toolResultCount( + part: ToolPart, + argsRecord: Record<string, unknown>, + resultRecord: Record<string, unknown> +): CountMetric | null { + if (part.result === undefined) { + return null + } + + const fallbackNounByTool = fallbackCountNoun(part.toolName) + + if (part.toolName === 'web_search') { + const hits = collectResultItems(part.result) + + if (hits.length) { + return countMetric(hits.length, 'result') + } + } + + const directCount = countFromRecord(resultRecord, fallbackNounByTool) + + if (directCount !== null) { + return normalizeMetricForTool(part.toolName, directCount) + } + + const payload = unwrapToolPayload(part.result) + + if (isRecord(payload)) { + const payloadCount = countFromRecord(payload, fallbackNounByTool) + + if (payloadCount !== null) { + return normalizeMetricForTool(part.toolName, payloadCount) + } + } + + const summaryText = + firstStringField(resultRecord, ['summary', 'message', 'detail']) || fallbackDetailText(argsRecord, resultRecord) + + const textMetric = countFromText(summaryText, fallbackNounByTool) + + return textMetric ? normalizeMetricForTool(part.toolName, textMetric) : null +} + +function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value) +} + +function looksLikePath(value: string): boolean { + return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) +} + +export function isPreviewableTarget(target: string): boolean { + return Boolean( + target && + (/^file:\/\//i.test(target) || + /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) || + /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) + ) +} + +function stableHash(value: string): string { + let hash = 0 + + for (let index = 0; index < value.length; index += 1) { + hash = Math.imul(31, hash) + value.charCodeAt(index) + } + + return Math.abs(hash).toString(36) +} + +export function toolPartDisclosureId(part: ToolPart): string { + if (part.toolCallId) { + return `tool:${part.toolCallId}` + } + + return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}` +} + +export function toolGroupDisclosureId(parts: ToolPart[]): string { + return `tool-group:${parts.map(toolPartDisclosureId).join('|')}` +} + +const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i + +function findFirstUrl(...sources: unknown[]): string { + for (const src of sources) { + if (typeof src === 'string') { + const m = src.match(URL_PATTERN) + + if (m) { + return m[0] + } + } else if (src && typeof src === 'object') { + for (const v of Object.values(src as Record<string, unknown>)) { + const found = findFirstUrl(v) + + if (found) { + return found + } + } + } + } + + return '' +} + +function hostnameOf(value: string): string { + try { + const url = new URL(value) + + return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}` + } catch { + return value + } +} + +export function looksRedundant(title: string, detail: string): boolean { + if (!detail) { + return true + } + + const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim() + + return norm(title) === norm(detail) +} + +export function cleanVisibleText(text: string): string { + return text + .split(INLINE_CODE_SPLIT_RE) + .map(part => + part.startsWith('`') + ? part + : part + .replace(BACKTICK_NOISE_RE, '') + .replace(CITATION_MARKER_RE, '') + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => { + const normalized = normalizeExternalUrl(href) + + return `${label} ${normalized}` + }) + ) + .join('') +} + +function summarizeBrowserSnapshot(snapshot: string): string { + const count = (re: RegExp) => snapshot.match(re)?.length ?? 0 + + const stats = [ + `${count(/button\s+"[^"]+"/g)} buttons`, + `${count(/link\s+"[^"]+"/g)} links`, + `${count(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)} inputs` + ].join(' · ') + + const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g)) + .map(m => m[1].trim()) + .filter(Boolean) + .slice(0, 4) + + return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats +} + +function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string { + for (const key of keys) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return '' +} + +function collectResultItems(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value + } + + const record = parseMaybeObject(value) + + for (const key of [ + 'web', + 'results', + 'search_results', + 'sources', + 'web_sources', + 'items', + 'organic_results', + 'organic', + 'matches', + 'documents' + ]) { + const candidate = record[key] + + if (Array.isArray(candidate)) { + return candidate + } + + if (isRecord(candidate)) { + const nested = collectResultItems(candidate) + + if (nested.length) { + return nested + } + } + } + + const payload = unwrapToolPayload(record) + + return payload === record ? [] : collectResultItems(payload) +} + +function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] { + const list = collectResultItems(result) + + return list + .map(item => { + const r = parseMaybeObject(item) + + return { + title: cleanVisibleText(firstStringField(r, ['title', 'name'])), + url: firstStringField(r, ['url', 'href', 'link']), + snippet: cleanVisibleText(firstStringField(r, ['snippet', 'description', 'body'])) + } + }) + .filter(hit => hit.title || hit.url) + .slice(0, limit) +} + +function toolErrorText(part: ToolPart, result: Record<string, unknown>): string { + const extractedError = extractToolErrorMessage(part.result) + + if (part.isError) { + return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.' + } + + if (typeof result.error === 'string' && result.error.trim()) { + return result.error.trim() + } + + if (extractedError) { + return extractedError + } + + if (result.success === false || result.ok === false) { + return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.' + } + + if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) { + return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".` + } + + const exit = numberValue(result.exit_code) + + return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : '' +} + +function toolStatus(part: ToolPart, resultRecord: Record<string, unknown>): ToolStatus { + if (part.result === undefined) { + return 'running' + } + + return toolErrorText(part, resultRecord) ? 'error' : 'success' +} + +function durationLabel(resultRecord: Record<string, unknown>): string | undefined { + const seconds = numberValue(resultRecord.duration_s) + + if (seconds === null || seconds < 0) { + return undefined + } + + return formatDurationSeconds(seconds) +} + +function toolPreviewTarget(toolName: string, args: Record<string, unknown>, result: Record<string, unknown>): string { + const direct = + firstStringField(result, ['preview', 'url', 'target']) || + firstStringField(args, ['preview', 'url', 'target', 'path', 'file', 'filepath']) || + firstStringField(result, ['path', 'file', 'filepath']) + + if (direct && (looksLikeUrl(direct) || looksLikePath(direct))) { + return direct + } + + if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') { + const explicit = firstStringField(args, ['url', 'search_term', 'query']) || firstStringField(result, ['url']) + + return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result) + } + + if (toolName === 'write_file' || toolName === 'edit_file') { + return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff'])) + } + + return '' +} + +function toolImageUrl(args: Record<string, unknown>, result: Record<string, unknown>): string { + const candidate = + firstStringField(result, ['image_url', 'url', 'path', 'image_path']) || + firstStringField(args, ['image_url', 'url', 'path']) + + if (!candidate) { + return '' + } + + return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate) + ? candidate + : '' +} + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') +} + +export function stripInlineDiffChrome(value: string): string { + return value + ? stripAnsi(value) + .replace(/^\s*┊\s*review diff\s*\n/i, '') + .trim() + : '' +} + +function htmlPathFromInlineDiff(value: string): string { + const cleaned = stripInlineDiffChrome(value) + + for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) { + const candidate = match[1]?.trim() + + if (candidate) { + return candidate + } + } + + return '' +} + +function stripDividerLines(value: string): string { + return value + .split('\n') + .filter(line => !/^[-=]{3,}\s*$/.test(line.trim())) + .join('\n') + .trim() +} + +export function inlineDiffFromResult(result: unknown): string { + const value = parseMaybeObject(result).inline_diff + + return typeof value === 'string' ? stripInlineDiffChrome(value) : '' +} + +// Falls back to a string only when there's something concrete to render — +// counts of opaque items/fields are noise, not signal. +function minimalValueSummary(value: unknown): string { + if (value == null) { + return '' + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + return '' +} + +function fallbackDetailText(args: unknown, result: unknown): string { + const argContext = contextValue(args) + const resultContext = contextValue(result) + + if (resultContext && resultContext !== argContext) { + return resultContext + } + + if (argContext) { + return argContext + } + + if (result !== undefined) { + return formatToolResultSummary(result) || minimalValueSummary(result) + } + + return formatToolResultSummary(args) || minimalValueSummary(args) +} + +function toolSubtitle( + part: ToolPart, + argsRecord: Record<string, unknown>, + resultRecord: Record<string, unknown> +): string { + const toolName = part.toolName + + if (toolName === 'browser_navigate') { + const url = + firstStringField(argsRecord, ['url', 'target']) || + firstStringField(resultRecord, ['url']) || + findFirstUrl(argsRecord, resultRecord) + + return url ? hostnameOf(url) : 'Navigated in browser' + } + + if (toolName === 'browser_snapshot') { + const snapshot = firstStringField(resultRecord, ['snapshot']) + + return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot' + } + + if (toolName === 'browser_click') { + const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target']) + + if (!clicked) { + return 'Clicked on page' + } + + return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}` + } + + if (toolName === 'browser_fill' || toolName === 'browser_type') { + const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target']) + const value = firstStringField(argsRecord, ['value', 'text']) + + return ( + [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') || + 'Filled page input' + ) + } + + if (toolName === 'web_search') { + const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) + + return query ? `Query: ${query}` : 'Queried web sources' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) + + const lines = Array.isArray(resultRecord.lines) + ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') + : '' + + const previewSource = (output || lines).trim() + + if (previewSource) { + const firstMeaningfulLine = previewSource + .split('\n') + .map(line => line.trim()) + .find(line => line.length > 0) + + if (firstMeaningfulLine) { + return compactPreview(firstMeaningfulLine, 160) + } + } + + const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord) + + return command ? compactPreview(command, 120) : 'Executed command' + } + + if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') { + const path = + firstStringField(argsRecord, ['path', 'file', 'filepath']) || + htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff'])) + + return ( + path || + (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord)) + ) + } + + if (toolName === 'web_extract') { + const url = + firstStringField(argsRecord, ['url']) || + firstStringField(resultRecord, ['url']) || + findFirstUrl(argsRecord, resultRecord) + + return url ? hostnameOf(url) : 'Fetched webpage' + } + + return ( + compactPreview(formatToolResultSummary(part.result), 120) || + compactPreview(resultRecord, 120) || + compactPreview(argsRecord, 120) || + fallbackDetailText(argsRecord, resultRecord) + ) +} + +function toolDetailLabel(toolName: string): string { + if (toolName === 'web_search') { + return 'Details' + } + + if (toolName === 'browser_snapshot') { + return 'Snapshot summary' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + return 'Command output' + } + + return '' +} + +function toolDetailText( + part: ToolPart, + argsRecord: Record<string, unknown>, + resultRecord: Record<string, unknown> +): string { + if (part.toolName === 'browser_snapshot') { + const snapshot = firstStringField(resultRecord, ['snapshot']) + + return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord) + } + + if (part.toolName === 'terminal' || part.toolName === 'execute_code') { + const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) + + const lines = Array.isArray(resultRecord.lines) + ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') + : '' + + if (output || lines) { + return [output, lines].filter(Boolean).join('\n') + } + } + + if (part.toolName === 'web_extract') { + const direct = firstStringField(resultRecord, ['content', 'text', 'markdown', 'body', 'summary', 'message']) + + if (direct) { + return direct.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim() + } + + const results = Array.isArray(resultRecord.results) ? resultRecord.results : [] + + const aggregated = results + .map(item => { + const row = parseMaybeObject(item) + + return firstStringField(row, ['content', 'text', 'markdown', 'body']) + }) + .filter(Boolean) + .join('\n\n---\n\n') + + if (aggregated) { + return aggregated + } + } + + if (part.toolName === 'read_file') { + const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body']) + + if (content) { + return content + } + } + + if (part.toolName === 'write_file' || part.toolName === 'edit_file') { + return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord) + } + + if (part.toolName === 'web_search') { + const detail = fallbackDetailText(argsRecord, resultRecord) + const seconds = numberValue(resultRecord.duration_s) + const duration = seconds === null ? '' : formatDurationSeconds(seconds) + + if (!duration) { + return detail + } + + return detail + .replace(/^\s*-\s*Duration\s+S\s*:\s*[-+]?[\d.]+(?:e[-+]?\d+)?\s*$/gim, `- Duration: ${duration}`) + .replace(/\bDuration\s+S\s*:/gi, 'Duration:') + } + + return fallbackDetailText(argsRecord, resultRecord) +} + +export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } { + const args = parseMaybeObject(part.args) + const result = parseMaybeObject(part.result) + const detail = view.detail.trim() + const hasSubstantialOutput = detail.length > 16 + + if (part.toolName === 'terminal' || part.toolName === 'execute_code') { + if (hasSubstantialOutput) { + return { label: 'Copy output', text: detail } + } + + const command = firstStringField(args, ['command', 'code']) || contextValue(args) + + if (command) { + return { label: 'Copy command', text: command } + } + } + + if (part.toolName === 'web_extract') { + if (hasSubstantialOutput) { + return { label: 'Copy content', text: detail } + } + + const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result) + + if (url) { + return { label: 'Copy URL', text: url } + } + } + + if (part.toolName === 'browser_navigate') { + const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result) + + if (url) { + return { label: 'Copy URL', text: url } + } + } + + if (part.toolName === 'web_search') { + if (view.searchHits?.length) { + const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n') + + return { label: 'Copy results', text } + } + + const query = firstStringField(args, ['search_term', 'query']) || contextValue(args) + + if (query) { + return { label: 'Copy query', text: query } + } + } + + if (part.toolName === 'read_file') { + if (hasSubstantialOutput) { + return { label: 'Copy file', text: detail } + } + + const path = firstStringField(args, ['path', 'file', 'filepath']) + + if (path) { + return { label: 'Copy path', text: path } + } + } + + if (part.toolName === 'write_file' || part.toolName === 'edit_file') { + const path = firstStringField(args, ['path', 'file', 'filepath']) + + if (path) { + return { label: 'Copy path', text: path } + } + } + + if (detail) { + return { label: 'Copy output', text: detail } + } + + return { label: 'Copy', text: view.title } +} + +function dynamicTitle( + part: ToolPart, + args: Record<string, unknown>, + result: Record<string, unknown>, + fallback: string +): string { + const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past) + + if (part.toolName === 'web_extract') { + const url = findFirstUrl(args, result) + + return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback + } + + if (part.toolName === 'browser_navigate') { + const url = findFirstUrl(args, result) + + return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback + } + + if (part.toolName === 'web_search') { + const query = firstStringField(args, ['search_term', 'query']) || contextValue(args) + + return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback + } + + if (part.toolName === 'terminal' || part.toolName === 'execute_code') { + const command = firstStringField(args, ['command', 'code']) || contextValue(args) + + if (command) { + const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran') + + return `${verbText} · ${compactPreview(command, 160)}` + } + } + + return fallback +} + +export function buildToolView(part: ToolPart, inlineDiff: string): ToolView { + const argsRecord = parseMaybeObject(part.args) + const resultRecord = parseMaybeObject(part.result) + const meta = toolMeta(part.toolName) + const status = toolStatus(part, resultRecord) + const error = toolErrorText(part, resultRecord) + const baseTitle = part.result === undefined ? meta.pending : meta.done + const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle) + const titleEnriched = title !== baseTitle + const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord) + const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code' + const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle + const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord)) + + const detail = error + ? [error, detailBody] + .filter(Boolean) + .filter((value, index, list) => list.findIndex(entry => entry.trim() === value.trim()) === index) + .join('\n\n') + : detailBody + + const searchHits = + part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined + + const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord) + + return { + countLabel: resultCount ? formatCountLabel(resultCount) : undefined, + detail, + detailLabel: error ? 'Error details' : toolDetailLabel(part.toolName), + durationLabel: durationLabel(resultRecord), + icon: meta.icon, + imageUrl: toolImageUrl(argsRecord, resultRecord), + inlineDiff, + previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord), + rawArgs: prettyJson(part.args), + rawResult: prettyJson(part.result), + searchHits: searchHits?.length ? searchHits : undefined, + status, + subtitle, + title, + tone: meta.tone + } +} + +function isToolPart(part: unknown): part is ToolPart { + if (!part || typeof part !== 'object') { + return false + } + + const row = part as Record<string, unknown> + + return row.type === 'tool-call' && typeof row.toolName === 'string' +} + +export function groupToolParts(content: unknown): ToolPart[][] { + if (!Array.isArray(content)) { + return [] + } + + const groups: ToolPart[][] = [] + let current: ToolPart[] = [] + + for (const part of content) { + // todo parts render in their own hoisted panel; skip from grouped tools. + if (isToolPart(part) && part.toolName !== 'todo') { + current.push(part) + + continue + } + + if (current.length) { + groups.push(current) + current = [] + } + } + + if (current.length) { + groups.push(current) + } + + return groups +} + +export function groupStatus(parts: ToolPart[]): ToolStatus { + if (parts.some(p => p.result === undefined)) { + return 'running' + } + + const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result))) + const hasError = statuses.includes('error') + + if (!hasError) { + return 'success' + } + + return statuses.at(-1) === 'success' ? 'warning' : 'error' +} + +export function groupTitle(parts: ToolPart[]): string { + const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix))) + const verb = prefix?.verb || 'Tool' + + return `${verb} actions · ${parts.length} steps` +} + +export function groupPreviewTargets(parts: ToolPart[]): string[] { + const seen = new Set<string>() + const targets: string[] = [] + + for (const part of parts) { + const view = buildToolView(part, inlineDiffFromResult(part.result)) + const target = view.previewTarget + + if (target && isPreviewableTarget(target) && !seen.has(target)) { + seen.add(target) + targets.push(target) + } + } + + return targets +} + +export function groupFailedStepCount(parts: ToolPart[]): number { + return parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length +} + +export function groupTotalDurationLabel(parts: ToolPart[]): string { + const seconds = parts.reduce((sum, part) => { + const value = numberValue(parseMaybeObject(part.result).duration_s) + + return sum + (value && value > 0 ? value : 0) + }, 0) + + if (!seconds) { + return '' + } + + return formatDurationSeconds(seconds) +} + +export function groupTailSubtitle(parts: ToolPart[]): string { + const tail = parts.at(-1) + + return tail ? buildToolView(tail, '').subtitle : '' +} + +export function groupCopyText(parts: ToolPart[]): string { + return parts + .map(part => { + const view = buildToolView(part, '') + const lines = [view.title] + + if (view.subtitle && view.subtitle !== view.title) { + lines.push(view.subtitle) + } + + if (view.detail && view.detail !== view.subtitle) { + lines.push(view.detail) + } + + return lines.join('\n') + }) + .join('\n\n') +} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx new file mode 100644 index 000000000..5e2f75ae1 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -0,0 +1,517 @@ +'use client' + +import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react' +import { useShallow } from 'zustand/shallow' + +import { useElapsedSeconds } from '@/components/chat/activity-timer' +import { ActivityTimerText } from '@/components/chat/activity-timer-text' +import { CompactMarkdown } from '@/components/chat/compact-markdown' +import { DiffLines } from '@/components/chat/diff-lines' +import { DisclosureRow } from '@/components/chat/disclosure-row' +import { PreviewAttachment } from '@/components/chat/preview-attachment' +import { ZoomableImage } from '@/components/chat/zoomable-image' +import { BrailleSpinner } from '@/components/ui/braille-spinner' +import { Codicon } from '@/components/ui/codicon' +import { CopyButton } from '@/components/ui/copy-button' +import { FadeText } from '@/components/ui/fade-text' +import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' +import { AlertCircle, CheckCircle2 } from '@/lib/icons' +import { useEnterAnimation } from '@/lib/use-enter-animation' +import { cn } from '@/lib/utils' +import { $toolInlineDiffs } from '@/store/tool-diffs' +import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' + +import { + groupCopyText as buildGroupCopyText, + buildToolView, + cleanVisibleText, + groupFailedStepCount, + groupPreviewTargets, + groupStatus, + groupTitle, + groupTotalDurationLabel, + inlineDiffFromResult, + isPreviewableTarget, + looksRedundant, + type SearchResultRow, + selectMessageRunning, + stripInlineDiffChrome, + toolCopyPayload, + type ToolPart, + toolPartDisclosureId, + type ToolStatus +} from './tool-fallback-model' + +// Tool names that ChainToolFallback intercepts and renders as something +// other than a ToolEntry — they don't count toward "is this a group of +// tool calls?" because they have no visible tool block. +const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify']) + +// `true` when the current ToolEntry is being rendered inside a group +// wrapper. Lets ToolEntry suppress per-row chrome (timer / preview) that +// the group already shows. +const ToolEmbedContext = createContext(false) + +// Shared header chrome for tool rows. Both the single-tool DisclosureRow +// and the multi-tool group header pass through these constants so a +// "Patch" row and a "Tool actions · 2 steps" row are visually identical. +const TOOL_HEADER_TITLE_CLASS = + 'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)' + +const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)' + +const TOOL_HEADER_SUBTITLE_CLASS = + 'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)' + +const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center self-center' + +// Glass-style section label that sits above any pre/JSON/output block. +// Lowercase tracking + tiny size so it reads as a quiet field label rather +// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc. +const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)' + +// Inset scroll surface for any detail body. The expanded tool row owns the +// border; the payload itself is just clipped raw text. +const TOOL_SECTION_SURFACE_CLASS = + 'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)' + +const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed') + +function rawTechnicalTrace(args: unknown, result: unknown): string { + const parts = [args, result] + .filter(value => value !== undefined && value !== null) + .map(value => { + if (typeof value === 'string') { + return value + } + + try { + return JSON.stringify(value) + } catch { + return String(value) + } + }) + .filter(Boolean) + + return parts.join('\n') +} + +function statusGlyph(status: ToolStatus): ReactNode { + if (status === 'running') { + return ( + <BrailleSpinner + ariaLabel="Running" + className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)" + spinner="breathe" + /> + ) + } + + if (status === 'error') { + return <AlertCircle aria-label="Error" className="size-3.5 shrink-0 text-destructive" /> + } + + if (status === 'warning') { + return <AlertCircle aria-label="Recovered" className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" /> + } + + return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" /> +} + +// Leading glyph for any tool-row header. Status (running/error/warning) +// takes precedence; otherwise falls back to the tool's codicon. Returns +// null when neither applies so callers can render unconditionally. +function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) { + const node = status ? ( + statusGlyph(status) + ) : icon ? ( + <Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" /> + ) : null + + return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null +} + +// Which status (if any) should pre-empt the tool's icon in the leading +// slot. Success is silent — the row reads as "done" without a checkmark. +function leadingStatus(isPending: boolean, status: ToolStatus): ToolStatus | undefined { + if (isPending) { + return 'running' + } + + return status === 'success' ? undefined : status +} + +function SearchResultsList({ hits }: { hits: SearchResultRow[] }) { + return ( + <ol className="m-0 grid list-none gap-2.5 p-0"> + {hits.map((hit, index) => { + const key = `${hit.url || hit.title}-${index}` + const trimmedTitle = hit.title.trim() + + return ( + <li className="grid min-w-0 gap-0.5" key={key}> + {hit.url ? ( + <PrettyLink + className={cn(TOOL_HEADER_TITLE_CLASS, 'block max-w-full')} + fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)} + href={hit.url} + label={trimmedTitle || undefined} + /> + ) : ( + <span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span> + )} + {hit.snippet && <p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>} + </li> + ) + })} + </ol> + ) +} + +function LinkifiedText({ className, text }: { className?: string; text: string }) { + return <SharedLinkifiedText className={className} pretty text={cleanVisibleText(text)} /> +} + +interface ToolEntryProps { + part: ToolPart +} + +function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean { + const persistedOpen = useStore($toolDisclosureOpen(disclosureId)) + + return persistedOpen ?? fallbackOpen +} + +function ToolEntry({ part }: ToolEntryProps) { + const messageId = useAuiState(s => s.message.id) + const messageRunning = useAuiState(selectMessageRunning) + const embedded = useContext(ToolEmbedContext) + const toolViewMode = useStore($toolViewMode) + const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}` + const open = useDisclosureOpen(disclosureId) + const isPending = messageRunning && part.result === undefined + // Only animate entries that mount while their message is actively + // streaming — historical sessions mount with `messageRunning === false`, + // so they paint statically without a settle cascade. The wrapping group + // handles its own enter animation, so embedded children skip it. + const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`) + const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`) + const liveDiffs = useStore($toolInlineDiffs) + const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' + const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) + + // Stale parts (no result, but message stopped running) get a synthetic + // empty result so buildToolView treats them as completed-no-output. + const view = useMemo(() => { + const p = !isPending && part.result === undefined ? { ...part, result: {} } : part + + return buildToolView(p, inlineDiff) + }, [inlineDiff, isPending, part]) + + const detailSections = useMemo(() => { + if (!view.detail) { + return { body: '', summary: '' } + } + + if (view.status !== 'error') { + return { body: view.detail, summary: '' } + } + + const chunks = view.detail + .split(/\n\s*\n+/) + .map(chunk => chunk.trim()) + .filter(Boolean) + + const [summary = '', ...rest] = chunks + const subtitleNorm = view.subtitle.trim().toLowerCase() + const summaryDuplicatesSubtitle = summary && summary.toLowerCase() === subtitleNorm + + if (summaryDuplicatesSubtitle) { + return { body: rest.join('\n\n').trim(), summary: '' } + } + + return { body: rest.join('\n\n').trim(), summary } + }, [view.detail, view.status, view.subtitle]) + + const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail) + + const showDetail = + (view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) || + (view.status !== 'error' && + Boolean(view.detail) && + !looksRedundant(view.title, view.detail) && + !detailMatchesSubtitle) + + const renderDetailAsCode = + view.status !== 'error' && + (part.toolName === 'terminal' || part.toolName === 'execute_code' || part.toolName === 'read_file') + + const hasSearchHits = Boolean(view.searchHits?.length) + const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel + + const showRawSearchDrilldown = + part.toolName === 'web_search' && + part.result !== undefined && + toolViewMode !== 'technical' && + Boolean(view.rawResult.trim()) + + const hasExpandableContent = Boolean( + (view.previewTarget && isPreviewableTarget(view.previewTarget)) || + view.imageUrl || + showDetail || + hasSearchHits || + toolViewMode === 'technical' + ) + + const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view]) + + const trailing = + isPending && !embedded ? ( + <ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} /> + ) : !isPending && copyAction.text ? ( + <CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} /> + ) : undefined + + return ( + <div + className={cn( + 'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)', + open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)' + )} + data-slot="tool-block" + ref={enterRef} + > + <div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}> + <DisclosureRow + onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined} + open={open} + trailing={trailing} + > + <span className="flex min-w-0 items-center gap-1.5"> + <ToolGlyph icon={view.icon} status={leadingStatus(isPending, view.status)} /> + <FadeText + className={cn( + TOOL_HEADER_TITLE_CLASS, + isPending && 'shimmer text-(--ui-text-tertiary)', + view.status === 'error' && 'text-destructive', + view.status === 'warning' && 'text-amber-700 dark:text-amber-300' + )} + > + {view.title} + </FadeText> + {!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>} + {!isPending && view.durationLabel && ( + <span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span> + )} + </span> + </DisclosureRow> + </div> + {open && ( + <div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5"> + {!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && ( + <PreviewAttachment source="tool-result" target={view.previewTarget} /> + )} + {view.imageUrl && ( + <div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)"> + <ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} /> + </div> + )} + {hasSearchHits && view.searchHits && ( + <div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)"> + {searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>} + <SearchResultsList hits={view.searchHits} /> + </div> + )} + {showDetail && + toolViewMode !== 'technical' && + (view.status === 'error' ? ( + detailSections.summary || detailSections.body ? ( + <div className="max-w-full text-xs leading-relaxed text-destructive"> + {detailSections.summary && ( + <LinkifiedText className="block font-medium" text={detailSections.summary} /> + )} + {detailSections.body && ( + <pre + className={cn( + 'max-h-56 overflow-auto whitespace-pre-wrap wrap-anywhere font-mono text-[0.7rem] leading-[1.55] text-destructive/90', + detailSections.summary && 'mt-1.5' + )} + > + {detailSections.body} + </pre> + )} + </div> + ) : null + ) : ( + <div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)"> + {view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>} + {renderDetailAsCode ? ( + <pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre> + ) : ( + <CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} /> + )} + </div> + ))} + {showRawSearchDrilldown && ( + <details className="max-w-full"> + <summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>Raw response</summary> + <pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}> + {view.rawResult} + </pre> + </details> + )} + {toolViewMode === 'technical' && ( + <pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}> + {rawTechnicalTrace(part.args, part.result)} + </pre> + )} + </div> + )} + {view.inlineDiff && <DiffLines text={view.inlineDiff} />} + </div> + ) +} + +/** + * Always-present wrapper around the consecutive tool-call range that + * `MessagePrimitive.Parts` already grouped for us. Renders a header + + * collapsible body when there are 2+ visible tools; otherwise it's a + * transparent passthrough that just owns the entry animation for the + * single ToolEntry inside. + * + * Crucially, the wrapper element is the SAME `<div>` regardless of + * group size — only the optional header element appears/disappears. + * That preserves React identity for the inner `MessagePartByIndex` + * children when the 1→2 transition happens, so existing tool blocks + * never remount when a new tool joins them mid-stream. + * + * The previous design (per-tool ToolFallback computing its own group + * lookup and conditionally returning either `<ToolEntry>` or + * `<ToolGroup>`) flipped the React element type at the 1→2 transition + * and tore down the existing tool entirely, which is what showed up as + * "the previous tool's animation resets every time a new tool arrives." + */ +export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({ + children, + endIndex, + startIndex +}) => { + const messageId = useAuiState(s => s.message.id) + const messageRunning = useAuiState(selectMessageRunning) + + // Pull the visible tool parts in this range. `useShallow` makes this + // re-render only when the actual part references change (assistant-ui + // gives stable refs for unchanged parts), not on every text/reasoning + // delta elsewhere in the message. + const visibleParts = useAuiState( + useShallow((s: { message: { parts: readonly unknown[] } }) => + s.message.parts.slice(startIndex, endIndex + 1).filter((p): p is ToolPart => { + if (!p || typeof p !== 'object') { + return false + } + + const row = p as { toolName?: unknown; type?: unknown } + + return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName) + }) + ) + ) + + const isGroup = visibleParts.length > 1 + const isRunning = messageRunning && visibleParts.some(p => p.result === undefined) + // Stable across the group's lifetime (start index doesn't shift when + // tools append to the end), so user-driven open/close persists across + // streaming. + const disclosureId = `tool-group:${messageId}:${startIndex}` + const open = useDisclosureOpen(disclosureId) + const enterRef = useEnterAnimation(messageRunning, disclosureId) + + const status = groupStatus(visibleParts) + const displayStatus = !isRunning && status === 'running' ? 'success' : status + const failedStepCount = useMemo(() => groupFailedStepCount(visibleParts), [visibleParts]) + const totalDurationLabel = useMemo(() => groupTotalDurationLabel(visibleParts), [visibleParts]) + + const statusSummary = + displayStatus === 'running' || failedStepCount === 0 + ? '' + : displayStatus === 'warning' + ? failedStepCount === 1 + ? 'Recovered after 1 failed step' + : `Recovered after ${failedStepCount} failed steps` + : failedStepCount === 1 + ? '1 step failed' + : `${failedStepCount} steps failed` + + const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts]) + const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts]) + + return ( + <ToolEmbedContext.Provider value={isGroup}> + <div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" ref={enterRef}> + {isGroup && ( + <DisclosureRow + key="header" + onToggle={() => setToolDisclosureOpen(disclosureId, !open)} + open={open} + trailing={ + !isRunning && groupCopyText ? ( + <CopyButton appearance="tool-row" label="Copy activity" stopPropagation text={groupCopyText} /> + ) : undefined + } + > + <span className="flex min-w-0 items-center gap-1.5"> + <ToolGlyph status={displayStatus === 'success' ? undefined : displayStatus} /> + <FadeText + className={cn( + TOOL_HEADER_TITLE_CLASS, + displayStatus === 'error' && 'text-destructive', + displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300' + )} + > + {groupTitle(visibleParts)} + </FadeText> + {totalDurationLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>} + </span> + {statusSummary && ( + <FadeText + className={cn( + TOOL_HEADER_SUBTITLE_CLASS, + displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85' + )} + > + {statusSummary} + </FadeText> + )} + </DisclosureRow> + )} + {isGroup && previewTargets.length > 0 && ( + <div className="mt-2 grid w-full min-w-0 max-w-full gap-2 overflow-hidden pr-2 pl-3"> + {previewTargets.map(target => ( + <PreviewAttachment key={target} source="tool-result" target={target} /> + ))} + </div> + )} + {/* Body is always rendered so children stay mounted across collapse/ + expand and across the 1→2 group transition. `hidden` removes it + from a11y/visual flow without unmounting React subtree. */} + <div className={cn(isGroup && 'mt-0.5 w-full overflow-hidden pr-2 pl-3')} hidden={isGroup && !open} key="body"> + {children} + </div> + </div> + </ToolEmbedContext.Provider> + ) +} + +/** + * Per-tool fallback. Now strictly returns a single ToolEntry — the + * grouping decision lives in ToolGroupSlot above, so this never swaps + * its return type and the underlying ToolEntry stays mounted across + * group-shape changes. + */ +export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: ToolCallMessagePartProps) => { + const part: ToolPart = { args, isError, result, toolCallId, toolName, type: 'tool-call' } + + return <ToolEntry part={part} /> +} diff --git a/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx b/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx new file mode 100644 index 000000000..76bce0da2 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx @@ -0,0 +1,31 @@ +'use client' + +import { type ComponentPropsWithRef, forwardRef } from 'react' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof Button> { + tooltip: string + side?: 'top' | 'bottom' | 'left' | 'right' +} + +export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>( + ({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => { + return ( + <Button + size="icon" + variant="ghost" + {...rest} + aria-label={tooltip} + className={cn('aui-button-icon size-6 p-1', className)} + ref={ref} + title={tooltip} + > + {children} + </Button> + ) + } +) + +TooltipIconButton.displayName = 'TooltipIconButton' diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx new file mode 100644 index 000000000..943981302 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -0,0 +1,129 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons' +import { $desktopBoot } from '@/store/boot' +import { $desktopOnboarding } from '@/store/onboarding' + +type BusyAction = 'local' | 'repair' | 'retry' | null + +// Recovery surface for a hard boot failure (gateway never came up, backend +// exited during startup, bootstrap latched, …). Without this the app shell +// renders dead — "gateway offline", no composer, only a toast — with no way +// to retry, repair the install, switch the gateway, or find the logs. +export function BootFailureOverlay() { + const boot = useStore($desktopBoot) + const onboarding = useStore($desktopOnboarding) + const [busy, setBusy] = useState<BusyAction>(null) + const [logs, setLogs] = useState<string[]>([]) + const [showLogs, setShowLogs] = useState(false) + + const visible = Boolean(boot.error) && !boot.running + // While first-run onboarding owns the picker/flow we let it surface its own + // progress; the recovery overlay is for hard failures, which it covers via a + // higher z-index regardless of onboarding state. + const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error' + + useEffect(() => { + if (!visible) { + return + } + + void window.hermesDesktop + ?.getRecentLogs() + .then(res => setLogs(res.lines ?? [])) + .catch(() => undefined) + }, [visible]) + + if (!visible || suppressed) { + return null + } + + const retry = async () => { + setBusy('retry') + await window.hermesDesktop?.resetBootstrap().catch(() => undefined) + window.location.reload() + } + + const repair = async () => { + setBusy('repair') + await window.hermesDesktop?.repairBootstrap().catch(() => undefined) + window.location.reload() + } + + const switchToLocalGateway = async () => { + setBusy('local') + // applyConnectionConfig reloads the window from the main process. + await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined) + setBusy(null) + } + + const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined) + + return ( + <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> + <div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm"> + <div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4"> + <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive"> + <AlertTriangle className="size-5" /> + </div> + <div> + <h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2> + <p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)"> + The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your + chats or settings. + </p> + </div> + </div> + + <div className="grid gap-4 p-5"> + <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive"> + {boot.error} + </div> + + <div className="grid gap-2"> + <div className="flex flex-wrap gap-2"> + <Button disabled={Boolean(busy)} onClick={() => void retry()}> + {busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />} + Retry + </Button> + <Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline"> + {busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />} + Repair install + </Button> + <Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline"> + {busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null} + Use local gateway + </Button> + <Button onClick={openLogs} variant="ghost"> + <FileText className="size-4" /> + Open logs + </Button> + </div> + <p className="text-xs text-muted-foreground"> + Repair re-runs the installer and can take a few minutes on a fresh machine. + </p> + </div> + + {logs.length > 0 ? ( + <div className="grid gap-2"> + <button + className="self-start text-xs font-medium text-muted-foreground transition hover:text-foreground" + onClick={() => setShowLogs(v => !v)} + type="button" + > + {showLogs ? 'Hide' : 'Show'} recent logs + </button> + {showLogs ? ( + <pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground"> + {logs.slice(-40).join('')} + </pre> + ) : null} + </div> + ) : null} + </div> + </div> + </div> + ) +} diff --git a/apps/desktop/src/components/chat/activity-timer-text.tsx b/apps/desktop/src/components/chat/activity-timer-text.tsx new file mode 100644 index 000000000..aa439eb24 --- /dev/null +++ b/apps/desktop/src/components/chat/activity-timer-text.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils' + +import { formatElapsed } from './activity-timer' + +interface ActivityTimerTextProps { + seconds: number + className?: string +} + +export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) { + return ( + <span + className={cn( + // Tinted with --dt-midground (very low alpha) so the timer reads + // as part of the same "live signal" cluster as the dither block / + // arc-border / working-session dot, instead of being neutral chrome. + 'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-midground/55 tabular-nums', + className + )} + > + {formatElapsed(seconds)} + </span> + ) +} diff --git a/apps/desktop/src/components/chat/activity-timer.test.tsx b/apps/desktop/src/components/chat/activity-timer.test.tsx new file mode 100644 index 000000000..acc70a99e --- /dev/null +++ b/apps/desktop/src/components/chat/activity-timer.test.tsx @@ -0,0 +1,43 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer' + +function Probe({ active, timerKey }: { active: boolean; timerKey?: string }) { + const elapsed = useElapsedSeconds(active, timerKey) + + return <span data-testid="elapsed">{elapsed}</span> +} + +describe('useElapsedSeconds', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + __resetElapsedTimerRegistryForTests() + }) + + afterEach(() => { + vi.useRealTimers() + __resetElapsedTimerRegistryForTests() + }) + + it('keeps elapsed time stable across remounts for the same key', () => { + const first = render(<Probe active timerKey="tool:abc" />) + + act(() => { + vi.advanceTimersByTime(5_000) + }) + + expect(screen.getByTestId('elapsed').textContent).toBe('5') + + first.unmount() + + act(() => { + vi.advanceTimersByTime(3_000) + }) + + render(<Probe active timerKey="tool:abc" />) + + expect(screen.getByTestId('elapsed').textContent).toBe('8') + }) +}) diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts new file mode 100644 index 000000000..533dc5b37 --- /dev/null +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from 'react' + +// Module-level registry so timers survive component unmount/remount (e.g. +// when a tool row scrolls out and back). Keyed by caller-supplied timerKey; +// anonymous timers (no key) start fresh each mount. +const startedAtByKey = new Map<string, number>() + +function startedAt(key?: string): number { + if (!key) { + return Date.now() + } + + const existing = startedAtByKey.get(key) + + if (existing !== undefined) { + return existing + } + + const now = Date.now() + startedAtByKey.set(key, now) + + return now +} + +export function formatElapsed(seconds: number): string { + if (seconds < 60) { + return `${seconds}s` + } + + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` +} + +export function useElapsedSeconds(active = true, timerKey?: string): number { + const start = useRef(startedAt(timerKey)) + const lastKey = useRef(timerKey) + const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - start.current) / 1000))) + + if (lastKey.current !== timerKey) { + start.current = startedAt(timerKey) + lastKey.current = timerKey + } + + useEffect(() => { + if (!active) { + return + } + + if (timerKey) { + start.current = startedAt(timerKey) + } + + const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000))) + tick() + const id = window.setInterval(tick, 1000) + + return () => window.clearInterval(id) + }, [active, timerKey]) + + return elapsed +} + +export function __resetElapsedTimerRegistryForTests() { + startedAtByKey.clear() +} diff --git a/apps/desktop/src/components/chat/code-card.tsx b/apps/desktop/src/components/chat/code-card.tsx new file mode 100644 index 000000000..46997caa4 --- /dev/null +++ b/apps/desktop/src/components/chat/code-card.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' + +import { Codicon, type CodiconProps } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +/** + * Rounded-card shell for fenced code (and any equivalent: diffs, raw payloads, + * etc.) sized for the conversation column. Mirrors the expanded tool-row + * pattern so code blocks read as the same family of artifact. + */ +function CodeCard({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn( + 'min-w-0 max-w-full overflow-hidden rounded-[0.625rem] border border-border text-[length:var(--conversation-tool-font-size)] text-muted-foreground', + className + )} + data-slot="code-card" + {...props} + /> + ) +} + +function CodeCardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('flex items-center justify-between gap-2 border-b border-border px-2 py-1.5', className)} + data-slot="code-card-header" + {...props} + /> + ) +} + +function CodeCardTitle({ className, children, ...props }: React.ComponentProps<'span'>) { + return ( + <span + className={cn( + 'flex min-w-0 items-center gap-1.5 truncate text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-foreground/80', + className + )} + data-slot="code-card-title" + {...props} + > + {children} + </span> + ) +} + +function CodeCardIcon({ className, ...props }: CodiconProps) { + return ( + <Codicon + className={cn('shrink-0 text-[0.875rem] leading-none text-muted-foreground', className)} + data-slot="code-card-icon" + {...props} + /> + ) +} + +function CodeCardSubtitle({ className, ...props }: React.ComponentProps<'span'>) { + return ( + <span className={cn('font-normal text-muted-foreground', className)} data-slot="code-card-subtitle" {...props} /> + ) +} + +function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn( + 'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed', + className + )} + data-slot="code-card-body" + {...props} + /> + ) +} + +export { CodeCard, CodeCardBody, CodeCardHeader, CodeCardIcon, CodeCardSubtitle, CodeCardTitle } diff --git a/apps/desktop/src/components/chat/compact-markdown.tsx b/apps/desktop/src/components/chat/compact-markdown.tsx new file mode 100644 index 000000000..79e96e8fa --- /dev/null +++ b/apps/desktop/src/components/chat/compact-markdown.tsx @@ -0,0 +1,113 @@ +import type { ComponentProps, ElementType, FC } from 'react' +import { Streamdown } from 'streamdown' + +import { ExternalLink, ExternalLinkIcon } from '@/lib/external-link' +import { cn } from '@/lib/utils' + +// Compact markdown renderer for tool detail bodies. Same Streamdown pipeline +// as the file preview pane, with tighter typography and external-link routing +// so tools that emit markdown (tables, headings, links) render properly +// instead of being dumped as raw text. + +const TAG_CLASSES = { + blockquote: 'mt-2 mb-2 border-l-2 border-border/70 pl-2.5 italic text-muted-foreground/85', + h1: 'mt-3 mb-1.5 text-sm font-semibold tracking-tight text-foreground first:mt-0', + h2: 'mt-3 mb-1.5 text-[0.82rem] font-semibold tracking-tight text-foreground first:mt-0', + h3: 'mt-2.5 mb-1 text-[0.78rem] font-semibold text-foreground first:mt-0', + h4: 'mt-2 mb-1 text-[0.74rem] font-semibold text-foreground first:mt-0', + hr: 'my-2 border-border/50', + li: 'marker:text-muted-foreground/60', + ol: 'mb-2 list-decimal pl-5 last:mb-0', + p: 'mb-1.5 leading-relaxed last:mb-0', + pre: 'mb-2 overflow-x-auto rounded-md border border-border/60 bg-background/70 p-2 font-mono text-[0.7rem] leading-[1.55] last:mb-0', + td: 'px-2 py-1 align-top leading-snug', + th: 'px-2 py-1 text-left text-[0.62rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground/80', + thead: 'bg-muted/40', + ul: 'mb-2 list-disc pl-5 last:mb-0' +} as const + +function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) { + const Component = (({ className, ...rest }: ComponentProps<T>) => { + const Element = Tag as ElementType + + return <Element className={cn(TAG_CLASSES[Tag], className)} {...rest} /> + }) as FC<ComponentProps<T>> + + Component.displayName = `Md.${Tag}` + + return Component +} + +function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) { + if (!href || !/^https?:\/\//i.test(href)) { + return ( + <a + className={cn('font-medium underline underline-offset-4 decoration-current/20', className)} + href={href} + {...rest} + > + {children} + </a> + ) + } + + return ( + <ExternalLink className={cn('decoration-current/20', className)} href={href} showExternalIcon={false}> + {children} + <ExternalLinkIcon /> + </ExternalLink> + ) +} + +function MarkdownCode({ className, ...rest }: ComponentProps<'code'>) { + return ( + <code + className={cn('rounded bg-muted/80 px-1 py-px font-mono text-[0.86em] text-muted-foreground', className)} + {...rest} + /> + ) +} + +function MarkdownTable({ className, ...rest }: ComponentProps<'table'>) { + return ( + <div className="mb-2 max-w-full overflow-x-auto rounded-md border border-border/60 last:mb-0"> + <table + className={cn( + 'w-full border-collapse text-[0.72rem] [&_tr]:border-b [&_tr]:border-border/50 last:[&_tr]:border-0', + className + )} + {...rest} + /> + </div> + ) +} + +const COMPONENTS = { + a: MarkdownAnchor, + blockquote: tagged('blockquote'), + code: MarkdownCode, + h1: tagged('h1'), + h2: tagged('h2'), + h3: tagged('h3'), + h4: tagged('h4'), + hr: tagged('hr'), + li: tagged('li'), + ol: tagged('ol'), + p: tagged('p'), + pre: tagged('pre'), + table: MarkdownTable, + td: tagged('td'), + th: tagged('th'), + thead: tagged('thead'), + ul: tagged('ul') +} + +export function CompactMarkdown({ className, text }: { className?: string; text: string }) { + return ( + <div className={cn('max-w-full text-xs leading-relaxed text-muted-foreground/90 wrap-anywhere', className)}> + <Streamdown components={COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}> + {text} + </Streamdown> + </div> + ) +} diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx new file mode 100644 index 000000000..926b77edf --- /dev/null +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +/** + * Per-line classed renderer for unified diffs. Lives outside `CodeCard` so + * tool-result panels (already nested inside a tool card) don't double-shell; + * for markdown ` ```diff ` fences the standard `CodeCard` + Shiki path runs + * instead and gives equivalent coloring. + */ +interface DiffLineKind { + className?: string + match: (line: string) => boolean +} + +const DIFF_LINE_KINDS: DiffLineKind[] = [ + { + className: 'text-emerald-700 dark:text-emerald-300', + match: line => line.startsWith('+') && !line.startsWith('+++') + }, + { className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') }, + { className: 'text-sky-700 dark:text-sky-300', match: line => line.startsWith('@@') }, + { + className: 'text-muted-foreground/70', + match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60)) + } +] + +function classifyLine(line: string): string | undefined { + return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className +} + +interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> { + text: string +} + +export function DiffLines({ className, text, ...props }: DiffLinesProps) { + return ( + <pre + className={cn( + 'mt-2 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground', + className + )} + data-slot="diff-lines" + {...props} + > + {text.split('\n').map((line, index) => ( + <span className={cn('block min-w-max whitespace-pre', classifyLine(line))} key={`${index}-${line}`}> + {line || ' '} + </span> + ))} + </pre> + ) +} diff --git a/apps/desktop/src/components/chat/disclosure-row.tsx b/apps/desktop/src/components/chat/disclosure-row.tsx new file mode 100644 index 000000000..b528e10c6 --- /dev/null +++ b/apps/desktop/src/components/chat/disclosure-row.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react' + +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { cn } from '@/lib/utils' + +// Shared header row for any collapsible block (thinking, tool group, single +// tool). Each parent supplies its own outer wrapper (with the data-slot CSS +// uses to escape the message padding) and its own expanded body. +// +// Affordance: +// - No leading chevron; a caret appears to the RIGHT of the text on hover +// (and stays visible when the row is open). +// - The hover background is a tight content-shaped pill — sized to the +// title text, NOT the full row — and reaches just past the chevron with +// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab +// stretching to the message edge. +export function DisclosureRow({ + children, + onToggle, + open, + trailing +}: { + children: ReactNode + onToggle?: () => void + open: boolean + trailing?: ReactNode +}) { + return ( + <div className="group/disclosure-row relative flex w-full max-w-full min-w-0 text-(--ui-text-tertiary)"> + <button + aria-expanded={onToggle ? open : undefined} + className={cn( + // max-w-fit so the click target hugs the title text width — no + // background fill, just the cursor + the affordance caret. + 'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors', + onToggle + ? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none' + : 'cursor-default' + )} + disabled={!onToggle} + onClick={onToggle} + type="button" + > + <span className="flex min-w-0 flex-col gap-0.5">{children}</span> + {onToggle && ( + // Wrapper height matches the title row's actual line-height so the + // caret centres with the title, not the whole subtitle stack. + <span + className={cn( + 'flex h-(--conversation-line-height) shrink-0 items-center justify-center transition-opacity duration-150', + open + ? 'opacity-80' + : 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80' + )} + > + <DisclosureCaret open={open} /> + </span> + )} + </button> + {trailing && ( + <span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span> + )} + </div> + ) +} diff --git a/apps/desktop/src/components/chat/generated-image-context.tsx b/apps/desktop/src/components/chat/generated-image-context.tsx new file mode 100644 index 000000000..8b020bb7d --- /dev/null +++ b/apps/desktop/src/components/chat/generated-image-context.tsx @@ -0,0 +1,19 @@ +'use client' + +import { createContext, type ReactNode, useContext, useMemo, useState } from 'react' + +type Value = { + isPending: boolean + setPending: (pending: boolean) => void +} + +const Ctx = createContext<Value | null>(null) + +export function GeneratedImageProvider({ children }: { children: ReactNode }) { + const [isPending, setPending] = useState(false) + const value = useMemo(() => ({ isPending, setPending }), [isPending]) + + return <Ctx.Provider value={value}>{children}</Ctx.Provider> +} + +export const useGeneratedImageContext = () => useContext(Ctx) diff --git a/apps/desktop/src/components/chat/image-generation-placeholder.tsx b/apps/desktop/src/components/chat/image-generation-placeholder.tsx new file mode 100644 index 000000000..6bb9983e0 --- /dev/null +++ b/apps/desktop/src/components/chat/image-generation-placeholder.tsx @@ -0,0 +1,276 @@ +import { type FC, useCallback, useEffect, useRef } from 'react' + +import { useResizeObserver } from '@/hooks/use-resize-observer' + +type Rgb = { r: number; g: number; b: number } + +const RAMP = ' .,:;-=+*#%@' + +const FALLBACKS = { + card: { r: 255, g: 255, b: 255 }, + muted: { r: 240, g: 240, b: 239 }, + foreground: { r: 36, g: 36, b: 36 }, + primary: { r: 207, g: 128, b: 109 }, + ring: { r: 185, g: 121, b: 105 } +} satisfies Record<string, Rgb> + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) + +const smoothstep = (edge0: number, edge1: number, value: number) => { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1) + + return t * t * (3 - 2 * t) +} + +const parseColor = (value: string, fallback: Rgb): Rgb => { + const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i) + + if (hex) { + return { + r: Number.parseInt(hex[1], 16), + g: Number.parseInt(hex[2], 16), + b: Number.parseInt(hex[3], 16) + } + } + + const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i) + + return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback +} + +const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({ + r: Math.round(a.r + (b.r - a.r) * amount), + g: Math.round(a.g + (b.g - a.g) * amount), + b: Math.round(a.b + (b.b - a.b) * amount) +}) + +const rgba = ({ r, g, b }: Rgb, alpha: number) => `rgba(${r}, ${g}, ${b}, ${alpha})` + +const hash2 = (x: number, y: number) => { + const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453 + + return n - Math.floor(n) +} + +const noise2 = (x: number, y: number) => { + const xi = Math.floor(x) + const yi = Math.floor(y) + const xf = x - xi + const yf = y - yi + const u = xf * xf * (3 - 2 * xf) + const v = yf * yf * (3 - 2 * yf) + const a = hash2(xi, yi) + const b = hash2(xi + 1, yi) + const c = hash2(xi, yi + 1) + const d = hash2(xi + 1, yi + 1) + + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v +} + +const fbm = (x: number, y: number) => { + let value = 0 + let amplitude = 0.5 + let frequency = 1 + + for (let i = 0; i < 4; i += 1) { + value += amplitude * noise2(x * frequency, y * frequency) + frequency *= 2.04 + amplitude *= 0.52 + } + + return value +} + +const readTheme = () => { + const styles = getComputedStyle(document.documentElement) + + return { + card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card), + muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted), + foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground), + primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary), + ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring) + } +} + +const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => { + const rect = canvas.getBoundingClientRect() + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const width = Math.max(1, rect.width) + const height = Math.max(1, rect.height) + + canvas.width = Math.round(width * dpr) + canvas.height = Math.round(height * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + return { width, height } +} + +const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => { + const theme = readTheme() + const bg = ctx.createLinearGradient(0, 0, width, height) + bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1)) + bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1)) + bg.addColorStop(1, rgba(mix(theme.muted, theme.ring, 0.12), 1)) + ctx.fillStyle = bg + ctx.fillRect(0, 0, width, height) + + const cycle = (time * 0.028) % 1 + + const denoise = cycle < 0.82 ? smoothstep(0.02, 0.82, cycle) : 1 - smoothstep(0.82, 1, cycle) + + const fontSize = clamp(width / 58, 8, 13) + const cellWidth = fontSize * 0.78 + const cellHeight = fontSize * 1.28 + const cols = Math.ceil(width / cellWidth) + const rows = Math.ceil(height / cellHeight) + const centerX = 0.53 + Math.sin(time * 0.055) * 0.02 + const centerY = 0.5 + Math.cos(time * 0.048) * 0.02 + const timestep = Math.floor(time * 1.15) + const timestepBlend = smoothstep(0, 1, time * 1.15 - timestep) + + ctx.font = `${fontSize}px "SF Mono", "Cascadia Code", Menlo, Consolas, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + for (let row = -1; row <= rows + 1; row += 1) { + for (let col = -1; col <= cols + 1; col += 1) { + const x = col * cellWidth + cellWidth * 0.5 + const y = row * cellHeight + cellHeight * 0.5 + const nx = x / width + const ny = y / height + const dx = (nx - centerX) * 1.2 + const dy = (ny - centerY) * 0.95 + const radius = Math.hypot(dx, dy) + const angle = Math.atan2(dy, dx) + + const bloom = + Math.exp(-(radius * radius) / 0.075) * 0.72 + + Math.exp(-((radius - (0.28 + Math.sin(angle * 5 + time * 0.16) * 0.035)) ** 2) / 0.0028) * 0.8 + + const contour = + Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38 + + const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46 + + const latent = clamp(bloom + contour + stem, 0, 1) + const staticA = hash2(col + timestep * 19, row - timestep * 11) + + const staticB = hash2(col + (timestep + 1) * 19, row - (timestep + 1) * 11) + + const staticNoise = staticA + (staticB - staticA) * timestepBlend + const livingNoise = fbm(col * 0.12 + time * 0.024, row * 0.12 - time * 0.018) + const denoiseWave = Math.exp(-((radius - denoise * 0.62) ** 2) / 0.006) + + const signal = clamp( + staticNoise * (1 - denoise) + + latent * denoise + + (livingNoise - 0.45) * (0.45 - denoise * 0.26) + + denoiseWave * 0.3, + 0, + 1 + ) + + const dropoutA = hash2(col - timestep * 7, row + timestep * 13) + + const dropoutB = hash2(col - (timestep + 1) * 7, row + (timestep + 1) * 13) + + const dropout = dropoutA + (dropoutB - dropoutA) * timestepBlend + + if (dropout > 0.35 + signal * 0.68) { + continue + } + + const glyph = RAMP[clamp(Math.floor(signal * (RAMP.length - 1)), 0, RAMP.length - 1)] + + if (glyph === ' ') { + continue + } + + const jitter = (1 - denoise) * 1.35 + (1 - latent) * 0.45 + const jx = (noise2(col * 0.31, row * 0.31 + time * 0.09) - 0.5) * jitter + const jy = (noise2(col * 0.27 - time * 0.085, row * 0.27) - 0.5) * jitter + const tintAmount = clamp(latent * 0.7 + denoiseWave * 0.4, 0, 1) + const warm = mix(theme.primary, theme.ring, hash2(col, row)) + const tint = mix(theme.foreground, warm, tintAmount) + const alpha = clamp(0.12 + signal * 0.68 + denoiseWave * 0.16, 0, 0.86) + + if (signal > 0.58 && denoise > 0.34) { + ctx.fillStyle = rgba(theme.ring, alpha * 0.2) + ctx.fillText(glyph, x + jx + 0.75, y + jy - 0.45) + ctx.fillStyle = rgba(theme.primary, alpha * 0.18) + ctx.fillText(glyph, x + jx - 0.75, y + jy + 0.45) + } + + ctx.fillStyle = rgba(tint, alpha) + ctx.fillText(glyph, x + jx, y + jy) + } + } + + const veil = ctx.createRadialGradient( + width * centerX, + height * centerY, + 0, + width * centerX, + height * centerY, + Math.min(width, height) * (0.35 + denoise * 0.3) + ) + + veil.addColorStop(0, rgba(theme.card, 0.08 + denoise * 0.12)) + veil.addColorStop(0.52, rgba(theme.card, 0.05)) + veil.addColorStop(1, rgba(theme.card, 0)) + ctx.fillStyle = veil + ctx.fillRect(0, 0, width, height) +} + +const DiffusionCanvas: FC = () => { + const canvasRef = useRef<HTMLCanvasElement | null>(null) + const sizeRef = useRef({ width: 0, height: 0 }) + + const fitToContainer = useCallback(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + + if (!canvas || !ctx) { + return + } + + sizeRef.current = fitCanvas(canvas, ctx) + }, []) + + useResizeObserver(fitToContainer, canvasRef) + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + + if (!canvas || !ctx) { + return + } + + sizeRef.current = fitCanvas(canvas, ctx) + + let frame = requestAnimationFrame(function draw(now) { + const { width, height } = sizeRef.current + ctx.clearRect(0, 0, width, height) + drawAsciiDiffusion(ctx, width, height, now / 1000) + frame = requestAnimationFrame(draw) + }) + + return () => { + cancelAnimationFrame(frame) + } + }, []) + + return <canvas className="absolute inset-0 h-full w-full" ref={canvasRef} /> +} + +export const ImageGenerationPlaceholder: FC = () => { + return ( + <div aria-label="Rendering image" aria-live="polite" className="w-full max-w-136 self-start" role="status"> + <div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]"> + <DiffusionCanvas /> + </div> + </div> + ) +} diff --git a/apps/desktop/src/components/chat/intro-copy.jsonl b/apps/desktop/src/components/chat/intro-copy.jsonl new file mode 100644 index 000000000..2fe1db960 --- /dev/null +++ b/apps/desktop/src/components/chat/intro-copy.jsonl @@ -0,0 +1,75 @@ +{"personality":"helpful","headline":"Ready when you are","body":"Ask me to open a repo, run tests, fix a bug, or draft a PR. I'll walk through the steps with you."} +{"personality":"helpful","headline":"How can I help today?","body":"Point me at a file, paste an error, or describe what you're building. I'll take it from there."} +{"personality":"helpful","headline":"Let's get started","body":"Try: review my diff, run the test suite, or explain this function. Ask anything about your code."} +{"personality":"helpful","headline":"Tell me what you need","body":"I can edit files, run commands, search the web, and walk you through tricky bugs. Just describe the task."} +{"personality":"helpful","headline":"Hi, Hermes here","body":"Share a repo path or a question to start. I keep replies clear and link back to the files I touch."} +{"personality":"concise","headline":"Ready.","body":"Describe the task. I'll do it."} +{"personality":"concise","headline":"Waiting for input","body":"Paste code, errors, or a goal. Short answers, fast edits."} +{"personality":"concise","headline":"Go.","body":"Ask. I'll read files, run tests, ship patches. No filler."} +{"personality":"concise","headline":"Standing by","body":"One line is enough. I'll expand only when it matters."} +{"personality":"concise","headline":"Your move","body":"Command, question, or file path. I handle the rest."} +{"personality":"technical","headline":"Shell mounted. Awaiting input.","body":"Provide repo path, failing test, or stack trace. Tools: fs, git, exec, search, patch, http."} +{"personality":"technical","headline":"Agent loop idle","body":"Send a prompt to trigger tool calls. Supports multi-file edits, test runs, git ops, and web fetches."} +{"personality":"technical","headline":"Ready for dispatch","body":"Enter task. I will plan, call tools, verify output. Logs stream inline; diffs returned pre-apply."} +{"personality":"technical","headline":"Stdin open","body":"Accepts natural language or structured commands. Typical flow: read -> plan -> patch -> test -> report."} +{"personality":"technical","headline":"Tools initialized","body":"filesystem, terminal, git, browser, search. Describe the change; I return diffs and test output."} +{"personality":"creative","headline":"A blank repo, a waiting cursor","body":"What shall we build? Paste an idea, a half-broken function, or a dream. I'll sketch it into shape."} +{"personality":"creative","headline":"Fresh canvas, warm compiler","body":"Give me a spark - a feature, a refactor, a wild prototype - and I'll turn it into code you can run."} +{"personality":"creative","headline":"Let's make something","body":"Describe the thing that doesn't exist yet. I'll pull tests, files, and APIs into a working draft."} +{"personality":"creative","headline":"New file, new possibilities","body":"Bring an intent, not a spec. We can prototype fast, refine later, and rewrite the world in the margins."} +{"personality":"creative","headline":"The muse is patched in","body":"Tell me what you're chasing. I'll remix examples, adapt snippets, and leave a tidy commit behind."} +{"personality":"teacher","headline":"Class is in session","body":"Ask about any file, concept, or error. I'll explain the why, not just the fix, and show a worked example."} +{"personality":"teacher","headline":"What shall we learn today?","body":"Paste code to review, a bug to debug, or a concept to unpack. I'll guide you step by step."} +{"personality":"teacher","headline":"Ready to walk you through it","body":"Share the problem. I'll break it into parts, explain each, and leave you able to solve the next one alone."} +{"personality":"teacher","headline":"Bring me a question","body":"We'll read the code together, find the root cause, and build a mental model you can reuse next time."} +{"personality":"teacher","headline":"Let's start with the basics","body":"Name the topic or paste the snippet. Expect explanations, diagrams in prose, and practice prompts."} +{"personality":"kawaii","headline":"hiii! ready to help! (^_^)","body":"paste a bug or a file path and i'll fix it super gently. tests, diffs, PRs - all with extra care! *sparkle*"} +{"personality":"kawaii","headline":"hermes-chan is here! <3","body":"tell me what you're making! i love refactors, tiny helpers, and big scary repos alike (>w<)"} +{"personality":"kawaii","headline":"let's code together!! :3","body":"drop an error, a goal, or a whole folder. i'll tidy it up with lots of love and a clean commit message!"} +{"personality":"kawaii","headline":"awaiting your wish~","body":"one task at a time, done neatly! i can run tests, patch files, and make your repo feel cozy again <3"} +{"personality":"kawaii","headline":"ready and happy! (>.<)","body":"say hi or paste a stack trace! no task too small, no repo too tangled. we'll untangle it together!"} +{"personality":"catgirl","headline":"nya~ what are we hacking on?","body":"paste a file, paw at a bug, or toss me a repo. i'll pounce on failing tests and leave clean diffs, nyan~"} +{"personality":"catgirl","headline":"*stretches* ready to code, nya","body":"describe the task. i'll patch, test, and purr over your PR. careful - i nip at unused imports!"} +{"personality":"catgirl","headline":"mrrp! new session opened","body":"give me a goal and i'll chase it through the codebase. reads, edits, runs - all with a twitchy tail."} +{"personality":"catgirl","headline":"tail up, claws sheathed","body":"paste an error or a plan. i debug like i hunt: quietly, thoroughly, with the occasional zoomie."} +{"personality":"catgirl","headline":"nyaaa~ hermes reporting","body":"say the word and i'll read your files, run your tests, and curl up in your branch with a tidy commit."} +{"personality":"pirate","headline":"Ahoy! Ready to sail the repo","body":"Name yer quarry - a bug, a feature, a cursed test - and I'll chase it down, matey. Diffs for plunder."} +{"personality":"pirate","headline":"Hermes at the helm, arrr","body":"Point me at the charts (the code) and I'll patch the hull, fire the cannons (tests), hoist a clean PR."} +{"personality":"pirate","headline":"What be the task, cap'n?","body":"Paste an error or a plan, ye scurvy dog. I'll navigate the stack trace and bring back treasure: green tests."} +{"personality":"pirate","headline":"Anchors aweigh, keyboard ready","body":"Tell me where X marks the spot. I read, edit, and commit with the discipline of a proper crew, arrr."} +{"personality":"pirate","headline":"Yo ho! Awaitin' orders","body":"Throw me a bug, a repo path, or a wild idea. I'll plunder the docs and return with workin' code."} +{"personality":"shakespeare","headline":"Pray, what task dost thou bring?","body":"Speak thy bug, thy file, thy weary test, and I shall mend it with a scholar's hand and honest diff."} +{"personality":"shakespeare","headline":"Hark! Hermes standeth ready","body":"Name the code that vexeth thee. I shall read, revise, and render a patch most fair and clean."} +{"personality":"shakespeare","headline":"What news from thy repository?","body":"Present thy stack trace or thy dream. I'll traverse files, run tests, and report in plainest verse."} +{"personality":"shakespeare","headline":"The stage is set, the cursor blinks","body":"Describe thy aim, good sir or madam. Thy branches shall be trimmed, thy bugs cast from the realm."} +{"personality":"shakespeare","headline":"Speak, and I shall act","body":"A line of intent sufficeth. I read, I edit, I commit - and leave thy history unblemished."} +{"personality":"surfer","headline":"Yo dude, what's the task?","body":"Drop a file, a bug, a gnarly stack trace - I'll ride it out. Clean diffs, green tests, no wipeouts."} +{"personality":"surfer","headline":"Waves lookin' clean, ready to code","body":"Paste your repo path or the bug that's bumming you out. We'll paddle in, fix it, paddle out. Easy."} +{"personality":"surfer","headline":"Hangin' ten at the prompt","body":"Tell me the vibe: feature, refactor, hotfix. I'll run tests, ship the patch, and keep it mellow, brah."} +{"personality":"surfer","headline":"Stoked to help, bro","body":"Big bug? Little typo? Whole rewrite? Just point. I handle the code; you chill with the rad commits."} +{"personality":"surfer","headline":"Tide's up, cursor's blinking","body":"Name the task and we're off. I read, edit, test, and leave a commit smoother than a dawn patrol."} +{"personality":"noir","headline":"Another repo, another rainy night","body":"Tell me what's broken. I'll read the files, dust for prints, and leave a diff on the desk by morning."} +{"personality":"noir","headline":"The cursor blinks. So do I.","body":"You've got a bug. I've got patience and a terminal. Name the case and I'll work it till it talks."} +{"personality":"noir","headline":"Hermes. Code investigator.","body":"Paste the stack trace, the suspect file, the alibi. I read between the lines and return with the truth."} +{"personality":"noir","headline":"Quiet night, open prompt","body":"Every bug leaves a trail. Give me the repo and a lead - I'll follow it, patch it, and close the file."} +{"personality":"noir","headline":"No case too small","body":"A typo, a segfault, a whole rotten architecture - hand me the keys. I'll bring back clean tests."} +{"personality":"uwu","headline":"uwu ready to hewp!","body":"paste a buggy fiwe or a goaw~ i'll wead, patch, and test, aww with tiny pawprints on the diff owo"} +{"personality":"uwu","headline":"hermes-san is wistening","body":"teww me the task, no matter how smoww~ i pwomise cwean commits and gentwe refactors, nyuu~"} +{"personality":"uwu","headline":"*tiny keyboard sounds*","body":"dwop yur ewwor message hewe! i'll find the cuwpwit, fix it, and weave a happy test suite behind me owo"} +{"personality":"uwu","headline":"wet's fix things togedda!","body":"give me a wepo path ow a buggo and i'll take cawe of it uwu. gwr at bad code, kind to yu~"} +{"personality":"uwu","headline":"awaiting yur command!","body":"i can wun tests, edit fiwes, and open pwease-wook PRs. just say da wowd, fwend uwu"} +{"personality":"philosopher","headline":"To code is to inquire. Ask.","body":"What problem sits before you? Describe it, and we shall examine its form, its cause, and its solution."} +{"personality":"philosopher","headline":"A blinking cursor, an open mind","body":"Every bug is a question in disguise. Share yours; I'll read, reason, and return an answer - and a patch."} +{"personality":"philosopher","headline":"Begin with a single question","body":"What do you wish to build, or to understand? I'll reason from first principles, edit, and verify with tests."} +{"personality":"philosopher","headline":"Consider the code, then speak","body":"Describe the end you seek. I pursue it through files, tests, and docs, and report what I found on the way."} +{"personality":"philosopher","headline":"The unexamined repo is not worth running","body":"Share a path, a puzzle, or a principle. I'll trace the logic, propose a change, and justify each edit."} +{"personality":"hype","headline":"LET'S GOOOO! READY TO SHIP!","body":"Paste that bug, that repo, that wild feature idea - I AM LOCKED IN. Clean diffs. Green tests. RIGHT NOW."} +{"personality":"hype","headline":"HERMES ONLINE. LFG.","body":"Drop your task and watch me cook. Files read, tests run, PRs opened - we are NOT losing today, friend."} +{"personality":"hype","headline":"New session, infinite W's","body":"Bring the gnarliest bug you've got. I'll read, patch, test, commit like my life depends on it. LET'S GO."} +{"personality":"hype","headline":"ABSOLUTELY DIALED IN","body":"Describe the task. I'll blitz through files, crush failing tests, and leave a commit that SLAPS. Go go go."} +{"personality":"hype","headline":"Ready. So ready. Too ready.","body":"Tiny typo or huge refactor - doesn't matter. I'm shipping clean code today. Name the task and let's WORK."} +{"personality":"none","headline":"Hermes Agent is ready.","body":"Ask a question, paste an error, or point me at a repo. I can read code, run tools, and help you ship."} +{"personality":"none","headline":"What are we building today?","body":"Describe the task in your own words. I'll pick the right tools, explain my plan, and check in before risky steps."} +{"personality":"none","headline":"Start anywhere.","body":"Drop a file path, a traceback, or a rough idea. I'll investigate, suggest next steps, and keep things reversible."} +{"personality":"none","headline":"Your workspace, one prompt away.","body":"Search the repo, edit files, run tests, open PRs. Tell me the goal and I'll handle the mechanical parts."} +{"personality":"none","headline":"Ready when you are.","body":"Type a task, question, or snippet. I remember the session, cite my sources, and stop to ask when I'm unsure."} diff --git a/apps/desktop/src/components/chat/intro.tsx b/apps/desktop/src/components/chat/intro.tsx new file mode 100644 index 000000000..7cd914c8d --- /dev/null +++ b/apps/desktop/src/components/chat/intro.tsx @@ -0,0 +1,181 @@ +import { type CSSProperties, useState } from 'react' + +import introCopyJsonl from './intro-copy.jsonl?raw' + +type IntroCopy = { + headline: string + body: string +} + +type IntroCopyRecord = IntroCopy & { + personality: string +} + +export type IntroProps = { + personality?: string + seed?: number +} + +const NEUTRAL_PERSONALITIES = new Set(['', 'default', 'none', 'neutral']) + +const FALLBACK_COPY: IntroCopy[] = [ + { + headline: 'What are we moving today?', + body: "Send a bug, branch, plan, or rough idea. I'll inspect the repo and turn it into the next concrete step." + }, + { + headline: "What's on your mind?", + body: "Bring the code, question, or stuck part. I'll read the room before making changes." + }, + { + headline: 'What should Hermes look at?', + body: "Send the task, failing path, or half-formed plan. I'll help turn it into action." + }, + { + headline: 'Where should we start?', + body: "Bring the problem, goal, or file. I'll inspect first and keep the next step concrete." + }, + { + headline: 'What needs attention?', + body: "Send the context you have. I'll help sort it into a plan or a fix." + } +] + +function normalizeKey(value?: string): string { + return (value || '').trim().toLowerCase() +} + +function titleize(value: string): string { + return value + .split(/[-_\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function isIntroCopyRecord(value: unknown): value is IntroCopyRecord { + if (!value || typeof value !== 'object') { + return false + } + + const record = value as Record<string, unknown> + + return ( + typeof record.personality === 'string' && + typeof record.headline === 'string' && + typeof record.body === 'string' && + Boolean(record.personality.trim()) && + Boolean(record.headline.trim()) && + Boolean(record.body.trim()) + ) +} + +function parseIntroCopy(raw: string): Record<string, IntroCopy[]> { + const byPersonality: Record<string, IntroCopy[]> = {} + + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim() + + if (!trimmed) { + continue + } + + try { + const parsed: unknown = JSON.parse(trimmed) + + if (!isIntroCopyRecord(parsed)) { + continue + } + + const key = normalizeKey(parsed.personality) + byPersonality[key] ??= [] + byPersonality[key].push({ + headline: parsed.headline.trim(), + body: parsed.body.trim() + }) + } catch { + // Bad generated copy should not break the whole desktop app. + } + } + + return byPersonality +} + +const INTRO_COPY_BY_PERSONALITY = parseIntroCopy(introCopyJsonl) + +function neutralCopy(): IntroCopy[] { + return INTRO_COPY_BY_PERSONALITY.none || INTRO_COPY_BY_PERSONALITY.default || FALLBACK_COPY +} + +function fallbackCopyForPersonality(personalityKey: string): IntroCopy[] { + if (NEUTRAL_PERSONALITIES.has(personalityKey)) { + return neutralCopy() + } + + const label = titleize(personalityKey) + + return [ + { + headline: `${label} mode is on. What should we work on?`, + body: "Send the task, file, or rough idea. I'll use your configured voice and keep the work grounded in this repo." + }, + { + headline: `What does ${label} Hermes need to see?`, + body: "Bring the context or the stuck part. I'll adapt to your configured personality." + }, + { + headline: `${label} mode is ready.`, + body: "Send the problem, file, or idea. I'll follow the personality you've configured." + }, + { + headline: `What should ${label} Hermes tackle?`, + body: "Drop the task here. I'll keep the work grounded in the repo." + }, + { + headline: 'Where should we begin?', + body: `Give me the context and I'll answer in ${label} mode.` + } + ] +} + +function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy { + return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0] +} + +function resolveCopy(personality?: string, seed?: number): IntroCopy { + const personalityKey = normalizeKey(personality) + + const copies = NEUTRAL_PERSONALITIES.has(personalityKey) + ? INTRO_COPY_BY_PERSONALITY[personalityKey] || neutralCopy() + : INTRO_COPY_BY_PERSONALITY[personalityKey] || fallbackCopyForPersonality(personalityKey) + + return pickCopy(copies, seed) +} + +export function Intro({ personality, seed }: IntroProps) { + const [mountSeed] = useState(() => Math.floor(Math.random() * 100000)) + const copy = resolveCopy(personality, mountSeed + (seed ?? 0)) + + return ( + <div + className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8" + data-slot="aui_intro" + > + <div className="w-full min-w-0"> + <p + className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90" + style={ + { '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties + } + > + <span> + <span>HERMES AGENT</span> + </span> + <span aria-hidden="true">HERMES AGENT</span> + </p> + + <p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p> + </div> + </div> + ) +} diff --git a/apps/desktop/src/components/chat/preview-attachment.tsx b/apps/desktop/src/components/chat/preview-attachment.tsx new file mode 100644 index 000000000..cc4c8ef2d --- /dev/null +++ b/apps/desktop/src/components/chat/preview-attachment.tsx @@ -0,0 +1,123 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef, useState } from 'react' + +import { MonitorPlay } from '@/lib/icons' +import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import { previewName } from '@/lib/preview-targets' +import { notifyError } from '@/store/notifications' +import { + $previewTarget, + dismissPreviewTarget, + type PreviewRecordSource, + setCurrentSessionPreviewTarget +} from '@/store/preview' +import { $currentCwd } from '@/store/session' + +export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) { + const cwd = useStore($currentCwd) + const activePreview = useStore($previewTarget) + const [opening, setOpening] = useState(false) + const activePreviewRef = useRef(activePreview) + const cwdRef = useRef(cwd) + const mountedRef = useRef(false) + const requestTokenRef = useRef(0) + const targetRef = useRef(target) + const name = previewName(target) + const isActive = activePreview?.source === target + + activePreviewRef.current = activePreview + cwdRef.current = cwd + targetRef.current = target + + useEffect(() => { + mountedRef.current = true + + return () => { + mountedRef.current = false + requestTokenRef.current += 1 + } + }, []) + + useEffect(() => { + requestTokenRef.current += 1 + setOpening(false) + }, [cwd, target]) + + async function togglePreview() { + if (opening) { + return + } + + if (isActive) { + dismissPreviewTarget() + + return + } + + const requestToken = ++requestTokenRef.current + const requestTarget = target + const requestCwd = cwd + + setOpening(true) + + try { + const preview = await normalizeOrLocalPreviewTarget(requestTarget, requestCwd || undefined) + + if ( + !mountedRef.current || + requestTokenRef.current !== requestToken || + targetRef.current !== requestTarget || + cwdRef.current !== requestCwd + ) { + return + } + + if (!preview) { + throw new Error(`Could not open preview target: ${requestTarget}`) + } + + const currentPreview = activePreviewRef.current + + if (currentPreview?.source === preview.source && currentPreview.url === preview.url) { + return + } + + setCurrentSessionPreviewTarget(preview, source, requestTarget) + } catch (error) { + if ( + !mountedRef.current || + requestTokenRef.current !== requestToken || + targetRef.current !== requestTarget || + cwdRef.current !== requestCwd + ) { + return + } + + notifyError(error, 'Preview unavailable') + } finally { + if (mountedRef.current && requestTokenRef.current === requestToken) { + setOpening(false) + } + } + } + + return ( + <div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm"> + <span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85"> + <MonitorPlay className="size-3.5" /> + </span> + <div className="min-w-0 flex-1"> + <div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div> + <div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div> + </div> + <button + className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]" + disabled={opening} + onClick={() => void togglePreview()} + type="button" + > + {opening ? 'Opening…' : isActive ? 'Hide' : 'Open preview'} + </button> + </div> + ) +} diff --git a/apps/desktop/src/components/chat/shiki-highlighter.tsx b/apps/desktop/src/components/chat/shiki-highlighter.tsx new file mode 100644 index 000000000..a0088e9ee --- /dev/null +++ b/apps/desktop/src/components/chat/shiki-highlighter.tsx @@ -0,0 +1,92 @@ +'use client' + +import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown' +import type { FC } from 'react' +import ShikiHighlighter from 'react-shiki' + +import { + CodeCard, + CodeCardBody, + CodeCardHeader, + CodeCardIcon, + CodeCardSubtitle, + CodeCardTitle +} from '@/components/chat/code-card' +import { CopyButton } from '@/components/ui/copy-button' +import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' + +/** + * Streamdown's code adapter renders header + body as inline siblings, so we + * own the wrapping `<CodeCard>` here and neutralize the upstream + * `data-streamdown="code-block"` chrome from styles.css. Anything that wants + * a card-shaped code surface should compose `CodeCard*` directly. + * + * `react-shiki` full bundle so all `bundledLanguages` work; theme switches + * follow the document `color-scheme` via `defaultColor="light-dark()"`. + */ +interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps { + defer?: boolean +} + +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const + +export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({ + components: { Pre }, + language, + code, + defer = false +}) => { + const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd() + + // Streaming may hand us empty/incomplete fences — render nothing rather + // than a transient empty card. + if (!trimmed.trim()) { + return null + } + + if (isLikelyProseCodeBlock(language, trimmed)) { + return <div className="aui-prose-fence whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div> + } + + const cleanLanguage = sanitizeLanguageTag(language || '') + const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : '' + + return ( + <CodeCard> + <CodeCardHeader> + <CodeCardTitle> + <CodeCardIcon name={codiconForLanguage(label)} /> + Code + {label && <CodeCardSubtitle> · {label}</CodeCardSubtitle>} + </CodeCardTitle> + <CopyButton + appearance="inline" + className="-my-1 -mr-1 h-5 px-1 opacity-55 hover:opacity-100" + iconClassName="size-2.5" + label="Copy code" + showLabel={false} + text={trimmed} + /> + </CodeCardHeader> + <CodeCardBody> + <Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0"> + {defer ? ( + <code className="block whitespace-pre">{trimmed}</code> + ) : ( + <ShikiHighlighter + addDefaultStyles={false} + as="div" + defaultColor="light-dark()" + delay={120} + language={language || 'text'} + showLanguage={false} + theme={SHIKI_THEME} + > + {trimmed} + </ShikiHighlighter> + )} + </Pre> + </CodeCardBody> + </CodeCard> + ) +} diff --git a/apps/desktop/src/components/chat/zoomable-image.tsx b/apps/desktop/src/components/chat/zoomable-image.tsx new file mode 100644 index 000000000..bc4882d07 --- /dev/null +++ b/apps/desktop/src/components/chat/zoomable-image.tsx @@ -0,0 +1,167 @@ +'use client' + +import { type ComponentProps, useState } from 'react' + +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Download } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' + +function imageFilename(src?: string): string { + if (!src) { + return 'image' + } + + try { + const { pathname } = new URL(src, window.location.href) + + return pathname.split('/').filter(Boolean).pop() || 'image' + } catch { + return src.split(/[\\/]/).filter(Boolean).pop() || 'image' + } +} + +function isMissingIpcHandler(error: unknown): boolean { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + + return message.includes("No handler registered for 'hermes:saveImageFromUrl'") +} + +async function startBrowserDownload(src: string) { + const response = await fetch(src) + + if (!response.ok) { + throw new Error(`Could not fetch image: ${response.status}`) + } + + const blobUrl = URL.createObjectURL(await response.blob()) + const link = document.createElement('a') + link.href = blobUrl + link.download = imageFilename(src) + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) +} + +export interface ZoomableImageProps extends ComponentProps<'img'> { + containerClassName?: string + slot?: string +} + +export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) { + const [saving, setSaving] = useState(false) + const [lightboxOpen, setLightboxOpen] = useState(false) + const canOpen = Boolean(src) + + async function handleDownload() { + if (!src || saving) { + return + } + + setSaving(true) + + try { + if (window.hermesDesktop?.saveImageFromUrl) { + const saved = await window.hermesDesktop.saveImageFromUrl(src) + + if (saved) { + notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) }) + } + + return + } + + await startBrowserDownload(src) + } catch (error) { + if (isMissingIpcHandler(error)) { + try { + await startBrowserDownload(src) + notify({ + kind: 'info', + title: 'Download started', + message: 'Restart Hermes Desktop to use Save Image.' + }) + } catch (fallbackError) { + notifyError(fallbackError, 'Restart Hermes Desktop to save images') + } + + return + } + + notifyError(error, 'Image download failed') + } finally { + setSaving(false) + } + } + + const lightbox = src ? ( + <Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}> + <DialogContent + className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none" + showCloseButton={false} + > + <div className="group/lightbox relative inline-block"> + <img + alt={alt ?? ''} + className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl" + onClick={() => setLightboxOpen(false)} + src={src} + /> + <ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" /> + </div> + </DialogContent> + </Dialog> + ) : null + + return ( + <> + <span + className={cn('group/image relative inline-block max-w-full align-top', containerClassName)} + data-slot={slot ?? 'aui_zoomable-image'} + > + <button + className="contents" + disabled={!canOpen} + onClick={() => canOpen && setLightboxOpen(true)} + title={canOpen ? 'Open image' : undefined} + type="button" + > + <img alt={alt ?? ''} className={className} src={src} {...props} /> + </button> + {src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />} + </span> + {lightbox} + </> + ) +} + +function ImageActionButton({ + onClick, + saving, + variant +}: { + onClick: () => void + saving: boolean + variant: 'inline' | 'lightbox' +}) { + return ( + <button + aria-label={saving ? 'Saving image' : 'Download image'} + className={cn( + 'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50', + variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100' + )} + disabled={saving} + onClick={event => { + event.stopPropagation() + void onClick() + }} + title={saving ? 'Saving image' : 'Download image'} + type="button" + > + <Download className={cn('size-4', saving && 'animate-pulse')} /> + </button> + ) +} diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx new file mode 100644 index 000000000..1864c6840 --- /dev/null +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -0,0 +1,512 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import type { + DesktopBootstrapEvent, + DesktopBootstrapStageDescriptor, + DesktopBootstrapStageResult, + DesktopBootstrapStageState, + DesktopBootstrapState +} from '@/global' + +/** + * DesktopInstallOverlay + * + * Renders the first-launch install progress for Hermes Agent. Mounted always; + * shows itself only when main.cjs reports an in-flight bootstrap (state.active) + * OR an error from a completed-failed bootstrap (state.error). When the + * bootstrap finishes successfully the overlay fades out and the rest of the + * app (existing onboarding overlay -> main UI) takes over. + * + * Subscribes to two channels: + * - getBootstrapState() -- initial snapshot on mount + * - onBootstrapEvent(callback) -- live event stream + * + * The reducer is intentionally simple: every event mutates an in-component + * snapshot the same way main.cjs mutates its server-side snapshot. We don't + * try to reconcile -- if we miss an event (shouldn't happen) the initial + * getBootstrapState() call will resync the picture on the next render. + * + * Stages flagged needs_user_input render with a deliberately subdued style: + * they're expected to come back as skipped=true (install.ps1 short-circuits + * them under -NonInteractive). The post-install configuration flow that + * those stages cover (API key, model, persona, gateway autostart) is handled + * by the existing DesktopOnboardingOverlay, NOT by the install overlay. + */ + +interface DesktopInstallOverlayProps { + /** When false, the overlay never renders -- useful for dev when we want + * to suppress it entirely. */ + enabled?: boolean +} + +interface StageRowProps { + descriptor: DesktopBootstrapStageDescriptor + result: DesktopBootstrapStageResult | undefined + isCurrent: boolean + now: number +} + +const STATE_LABEL: Record<DesktopBootstrapStageState, string> = { + pending: 'Pending', + running: 'Installing', + succeeded: 'Done', + skipped: 'Skipped', + failed: 'Failed' +} + +function formatStageName(name: string): string { + // 'system-packages' -> 'System packages'; 'uv' stays 'uv' + if (name.length <= 3) return name + return name + .split('-') + .map((word, i) => + i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word + ) + .join(' ') +} + +function formatDuration(ms: number | null | undefined): string { + if (typeof ms !== 'number' || !Number.isFinite(ms)) return '' + if (ms < 1000) return `${ms} ms` + const s = ms / 1000 + if (s < 60) return `${s.toFixed(1)}s` + const m = Math.floor(s / 60) + const rs = Math.round(s - m * 60) + return `${m}m ${rs}s` +} + +// Live elapsed for a running stage, as m:ss (or s for sub-minute). +function formatElapsed(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + return `${m}:${String(s - m * 60).padStart(2, '0')}` +} + +function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) { + const state: DesktopBootstrapStageState = result?.state || 'pending' + const elapsed = + state === 'running' && typeof result?.startedAt === 'number' ? formatElapsed(now - result.startedAt) : '' + const icon = useMemo(() => { + switch (state) { + case 'running': + return <Loader2 className="h-4 w-4 animate-spin text-primary" /> + case 'succeeded': + return <Check className="h-4 w-4 text-emerald-600" /> + case 'skipped': + return <Check className="h-4 w-4 text-muted-foreground" /> + case 'failed': + return <AlertTriangle className="h-4 w-4 text-destructive" /> + case 'pending': + default: + return <div className="h-2 w-2 rounded-full border border-muted-foreground/40" /> + } + }, [state]) + + const reason = result?.json?.reason || result?.error || null + + return ( + <li + className={cn( + 'flex items-start gap-3 rounded-md px-3 py-2 transition-colors', + isCurrent && 'bg-muted/60', + state === 'failed' && 'bg-destructive/10' + )} + > + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center"> + {icon} + </div> + <div className="min-w-0 flex-1"> + <div className="flex items-baseline justify-between gap-2"> + <span + className={cn( + 'truncate text-sm font-medium', + state === 'pending' && 'text-muted-foreground' + )} + > + {formatStageName(descriptor.name)} + </span> + <span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground"> + {state === 'running' ? (elapsed ? `${STATE_LABEL[state]} · ${elapsed}` : STATE_LABEL[state]) : null} + {state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null} + {state === 'failed' ? STATE_LABEL[state] : null} + </span> + </div> + {reason && state !== 'pending' && ( + <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p> + )} + </div> + </li> + ) +} + +const EMPTY_STATE: DesktopBootstrapState = { + active: false, + manifest: null, + stages: {}, + error: null, + log: [], + startedAt: null, + completedAt: null, + unsupportedPlatform: null +} + +function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState { + if (ev.type === 'manifest') { + const stages: Record<string, DesktopBootstrapStageResult> = {} + for (const stage of ev.stages) { + stages[stage.name] = { state: 'pending', durationMs: null, startedAt: null, json: null, error: null } + } + return { + ...state, + active: true, + manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion }, + stages, + error: null, + startedAt: state.startedAt || Date.now() + } + } + if (ev.type === 'stage') { + const prev = state.stages[ev.name] + return { + ...state, + stages: { + ...state.stages, + [ev.name]: { + state: ev.state, + durationMs: ev.durationMs ?? null, + // Stamp the start time on the running transition so the UI can show + // a live elapsed timer; preserve it across repeated running events. + startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null, + json: ev.json ?? null, + error: ev.error ?? null + } + } + } + } + if (ev.type === 'log') { + const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line }) + while (next.length > 500) next.shift() + return { ...state, log: next } + } + if (ev.type === 'complete') { + return { ...state, active: false, completedAt: Date.now(), error: null } + } + if (ev.type === 'failed') { + return { ...state, active: false, error: ev.error || 'unknown error' } + } + if (ev.type === 'unsupported-platform') { + return { + ...state, + active: false, + unsupportedPlatform: { + platform: ev.platform, + activeRoot: ev.activeRoot, + installCommand: ev.installCommand, + docsUrl: ev.docsUrl + } + } + } + return state +} + +export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) { + const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE) + const [logOpen, setLogOpen] = useState(false) + const [copied, setCopied] = useState(false) + const [now, setNow] = useState(() => Date.now()) + const logEndRef = useRef<HTMLDivElement | null>(null) + + // Tick once a second while a bootstrap is in flight so running steps show a + // live elapsed timer. Stops when nothing is active to avoid idle renders. + useEffect(() => { + if (!state.active) return + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, [state.active]) + + // Subscribe to bootstrap events + load initial snapshot + useEffect(() => { + if (!enabled) return + const desktop = window.hermesDesktop + if (!desktop || typeof desktop.onBootstrapEvent !== 'function') return + + let cancelled = false + + desktop + .getBootstrapState() + .then(snapshot => { + if (!cancelled && snapshot) setState(snapshot) + }) + .catch(() => { + // Older Electron build without the IPC handler -- bootstrap UI just + // stays empty, app falls through to existing onboarding flow. + }) + + const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev))) + return () => { + cancelled = true + off?.() + } + }, [enabled]) + + // Autoscroll log to bottom when new lines arrive AND the log is open + useEffect(() => { + if (logOpen && logEndRef.current) { + logEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' }) + } + }, [state.log.length, logOpen]) + + // Auto-expand the log panel when a bootstrap fails so the user immediately + // sees the install.ps1 output. Without this, the failure block shows just + // the top-level error message and the user has to click "Show installer + // output" to see WHY the stage failed. + useEffect(() => { + if (state.error) setLogOpen(true) + }, [state.error]) + + // Mount logic: show whenever a bootstrap is in flight, completed-with-error, + // or actively running with a manifest. Hide entirely after a successful + // completion so the rest of the UI can take over. + const shouldShow = useMemo(() => { + if (!enabled) return false + if (state.active) return true + if (state.error) return true + if (state.unsupportedPlatform) return true + return false + }, [enabled, state.active, state.error, state.unsupportedPlatform]) + + if (!shouldShow) return null + + // Unsupported-platform branch: macOS/Linux packaged builds hit this when + // there's no Hermes Agent installed yet and we can't drive install.sh + // (no stage protocol equivalent yet). Show a copy-paste install command + // and the docs URL; user runs it from Terminal and relaunches the app. + if (state.unsupportedPlatform) { + const ups = state.unsupportedPlatform + const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform + return ( + <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md"> + <div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl"> + <h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2> + <p className="mt-2 text-sm text-muted-foreground"> + Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and + run the command below, then relaunch this app. Subsequent launches will skip this step. + </p> + + <div className="mt-4"> + <div className="mb-1.5 text-xs font-medium text-muted-foreground">Install command</div> + <pre className="overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 font-mono text-[12px]"> + <code>{ups.installCommand}</code> + </pre> + <div className="mt-2 flex items-center gap-2"> + <Button + variant="secondary" + size="sm" + onClick={() => { + void navigator.clipboard?.writeText(ups.installCommand).catch(() => {}) + }} + > + Copy command + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => { + window.hermesDesktop?.openExternal?.(ups.docsUrl) + }} + > + View install docs + </Button> + </div> + </div> + + <div className="mt-6 flex items-center justify-between border-t pt-4"> + <span className="text-xs text-muted-foreground"> + Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code> + </span> + <Button + variant="default" + size="sm" + onClick={() => window.location.reload()} + > + I{'\u2019'}ve run it -- retry + </Button> + </div> + </div> + </div> + ) + } + + const stages = state.manifest?.stages || [] + const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name + const completedCount = stages.filter( + s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped' + ).length + const totalCount = stages.length + const failed = Boolean(state.error) + const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0 + const currentStartedAt = currentStage ? state.stages[currentStage]?.startedAt : null + const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : '' + + return ( + <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4"> + <div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border bg-card shadow-xl"> + {/* Header -- always visible, never scrolls */} + <div className="flex-shrink-0 p-8 pb-4"> + <h2 className="text-2xl font-semibold tracking-tight"> + {failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'} + </h2> + <p className="mt-1.5 text-sm text-muted-foreground"> + {failed + ? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.' + : 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' + + 'Subsequent launches will skip this step.'} + </p> + </div> + + {/* Scrollable middle: progress, stages, error block, log */} + <div className="min-h-0 flex-1 overflow-y-auto px-8 pb-2"> + {totalCount > 0 && ( + <div className="mb-4"> + <div className="mb-1 flex items-center justify-between text-xs text-muted-foreground"> + <span> + {completedCount} of {totalCount} steps complete + {currentStage && ` -- now: ${formatStageName(currentStage)}`} + {currentElapsed && ` (${currentElapsed})`} + </span> + <span className="tabular-nums">{progressPct}%</span> + </div> + <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"> + <div + className={cn( + 'h-full transition-all duration-300', + failed ? 'bg-destructive' : 'bg-primary' + )} + style={{ width: `${progressPct}%` }} + /> + </div> + </div> + )} + + {totalCount === 0 && state.active && ( + <div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span>Fetching installer manifest...</span> + </div> + )} + + {failed && state.error && ( + <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm"> + <div className="mb-1 flex items-center gap-1.5 font-medium text-destructive"> + <AlertTriangle className="h-4 w-4" /> + <span>Error</span> + </div> + <p className="whitespace-pre-wrap break-words text-foreground/90">{state.error}</p> + </div> + )} + + {stages.length > 0 && ( + <ol className="mb-4 space-y-1"> + {stages.map(stage => ( + <StageRow + key={stage.name} + descriptor={stage} + result={state.stages[stage.name]} + isCurrent={stage.name === currentStage} + now={now} + /> + ))} + </ol> + )} + + <div className="border-t pt-3"> + <button + type="button" + onClick={() => setLogOpen(v => !v)} + className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground" + > + {logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />} + <span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span> + <span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span> + </button> + + {logOpen && ( + <div className={cn( + 'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed', + failed ? 'max-h-96' : 'max-h-64' + )}> + {state.log.length === 0 ? ( + <div className="text-muted-foreground">No output yet.</div> + ) : ( + <> + {state.log.map((entry, i) => ( + <div key={i} className="whitespace-pre-wrap break-words"> + {entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null} + <span>{entry.line}</span> + </div> + ))} + <div ref={logEndRef} /> + </> + )} + </div> + )} + </div> + </div> + + {/* Footer -- always visible, never scrolls; only renders on failure */} + {failed && ( + <div className="flex-shrink-0 border-t bg-card p-4"> + <div className="flex items-center justify-between gap-2"> + <span className="text-xs text-muted-foreground"> + Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code> + </span> + <div className="flex gap-2"> + <Button + variant="secondary" + size="sm" + onClick={async () => { + const text = state.log + .map(entry => (entry.stage ? `[${entry.stage}] ${entry.line}` : entry.line)) + .join('\n') + const fullText = state.error ? `Error: ${state.error}\n\n${text}` : text + try { + await navigator.clipboard.writeText(fullText) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } catch { + // ignore -- some environments forbid clipboard writes + } + }} + > + {copied ? 'Copied!' : 'Copy output'} + </Button> + <Button + variant="default" + size="sm" + onClick={async () => { + // Tell main.cjs to clear its latched failure BEFORE we + // reload. Otherwise the renderer reload calls getConnection + // and main short-circuits to the latched error without + // re-running install.ps1. + try { + await window.hermesDesktop?.resetBootstrap?.() + } catch { + // best-effort -- continue with reload regardless + } + window.location.reload() + }} + > + Reload and retry + </Button> + </div> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx new file mode 100644 index 000000000..379642c99 --- /dev/null +++ b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { OAuthProvider } from '@/types/hermes' + +import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding' + +import { Picker } from './desktop-onboarding-overlay' + +function provider(id: string, name = id): OAuthProvider { + return { + cli_command: `hermes login ${id}`, + docs_url: `https://example.com/${id}`, + flow: 'pkce', + id, + name, + status: { logged_in: false } + } +} + +function setProviders(providers: OAuthProvider[]) { + $desktopOnboarding.set({ + configured: false, + flow: { status: 'idle' }, + mode: 'oauth', + providers, + reason: null, + requested: false, + manual: false + } satisfies DesktopOnboardingState) +} + +const ctx: OnboardingContext = { requestGateway: async () => undefined as never } + +afterEach(() => { + cleanup() + $desktopOnboarding.set({ + configured: null, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false, + manual: false + }) +}) + +describe('onboarding Picker', () => { + it('features Nous Portal and hides other providers behind a disclosure', () => { + setProviders([provider('anthropic', 'Anthropic Claude'), provider('nous', 'Nous Portal')]) + render(<Picker ctx={ctx} />) + + expect(screen.getByText('Nous Portal')).toBeTruthy() + expect(screen.getByText('Recommended')).toBeTruthy() + expect(screen.queryByText('Anthropic Claude')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Other providers' })) + + expect(screen.getByText('Anthropic Claude')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy() + }) + + it('shows every provider directly when Nous Portal is absent', () => { + setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')]) + render(<Picker ctx={ctx} />) + + expect(screen.getByText('Anthropic Claude')).toBeTruthy() + expect(screen.getByText('OpenAI Codex / ChatGPT')).toBeTruthy() + expect(screen.queryByText('Other sign-in options')).toBeNull() + expect(screen.queryByText('Recommended')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx new file mode 100644 index 000000000..efe81769e --- /dev/null +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -0,0 +1,765 @@ +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { ModelPickerDialog } from '@/components/model-picker' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { getGlobalModelOptions } from '@/hermes' +import { + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ExternalLink, + KeyRound, + Loader2, + Sparkles, + Terminal +} from '@/lib/icons' +import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { cn } from '@/lib/utils' +import { $desktopBoot, type DesktopBootState } from '@/store/boot' +import { + $desktopOnboarding, + cancelOnboardingFlow, + closeManualOnboarding, + confirmOnboardingModel, + copyDeviceCode, + copyExternalCommand, + type OnboardingContext, + type OnboardingFlow, + recheckExternalSignin, + refreshOnboarding, + saveOnboardingApiKey, + setOnboardingCode, + setOnboardingMode, + setOnboardingModel, + startProviderOAuth, + submitOnboardingCode +} from '@/store/onboarding' +import type { OAuthProvider } from '@/types/hermes' + +interface DesktopOnboardingOverlayProps { + enabled: boolean + onCompleted?: () => void + requestGateway: OnboardingContext['requestGateway'] +} + +interface ApiKeyOption { + description: string + docsUrl: string + envKey: string + id: string + name: string + placeholder?: string + short?: string +} + +const MIN_KEY_LENGTH = 8 + +const API_KEY_OPTIONS: ApiKeyOption[] = [ + { + id: 'openrouter', + name: 'OpenRouter', + short: 'one key, many models', + envKey: 'OPENROUTER_API_KEY', + description: 'Hosts hundreds of models behind a single key. Good default for new installs.', + docsUrl: 'https://openrouter.ai/keys' + }, + { + id: 'openai', + name: 'OpenAI', + short: 'GPT-class models', + envKey: 'OPENAI_API_KEY', + description: 'Direct access to OpenAI models.', + docsUrl: 'https://platform.openai.com/api-keys' + }, + { + id: 'gemini', + name: 'Google Gemini', + short: 'Gemini models', + envKey: 'GEMINI_API_KEY', + description: 'Direct access to Google Gemini models.', + docsUrl: 'https://aistudio.google.com/app/apikey' + }, + { + id: 'xai', + name: 'xAI Grok', + short: 'Grok models', + envKey: 'XAI_API_KEY', + description: 'Direct access to xAI Grok models.', + docsUrl: 'https://console.x.ai/' + }, + { + id: 'local', + name: 'Local / custom endpoint', + short: 'self-hosted', + envKey: 'OPENAI_BASE_URL', + description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).', + docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint', + placeholder: 'http://127.0.0.1:8000/v1' + } +] + +const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = { + nous: { order: 0, title: 'Nous Portal' }, + anthropic: { order: 1, title: 'Anthropic Claude' }, + 'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' }, + 'minimax-oauth': { order: 3, title: 'MiniMax' }, + 'claude-code': { order: 4, title: 'Claude Code' }, + 'qwen-oauth': { order: 5, title: 'Qwen Code' } +} + +const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}` + +const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = { + pkce: 'Opens your browser to sign in, then continues here', + device_code: 'Opens a verification page in your browser — Hermes connects automatically', + external: 'Sign in once in your terminal, then come back to chat' +} + +const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name +const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99 + +const sortProviders = (providers: OAuthProvider[]) => + [...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name)) + +export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) { + const onboarding = useStore($desktopOnboarding) + const boot = useStore($desktopBoot) + const ctxRef = useRef<OnboardingContext>({ requestGateway, onCompleted }) + ctxRef.current = { requestGateway, onCompleted } + + const ctx = useMemo<OnboardingContext>( + () => ({ + requestGateway: (...args) => ctxRef.current.requestGateway(...args), + onCompleted: () => ctxRef.current.onCompleted?.() + }), + [] + ) + + useEffect(() => { + if (enabled || onboarding.requested) { + void refreshOnboarding(ctx) + } + }, [ctx, enabled, onboarding.requested]) + + // Mount from frame 1 so we replace the boot overlay seamlessly. The + // configured field stays null until the runtime check resolves; only then + // do we know whether to dismiss (true) or surface the picker (false). + // EXCEPTION: manual mode (user opened the selector from a working app to + // add/switch a provider) shows the overlay regardless of configured state. + if (onboarding.configured === true && !onboarding.manual) { + return null + } + + const { flow } = onboarding + const rawReason = onboarding.reason?.trim() || null + const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null + // In manual mode the app is already configured, so the flow is "ready" + // immediately — no runtime gate needed. Otherwise wait for the readiness + // check (configured === false) before showing the picker. + const ready = onboarding.manual || (enabled && onboarding.configured === false) + const showPicker = flow.status === 'idle' || flow.status === 'success' + + return ( + <div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> + <div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm"> + <Header /> + <div className="grid gap-3 p-5"> + {onboarding.manual ? ( + <div className="flex justify-end"> + <button + className="text-xs font-medium text-muted-foreground transition hover:text-foreground" + onClick={() => closeManualOnboarding()} + type="button" + > + Close + </button> + </div> + ) : null} + {reason ? <ReasonNotice reason={reason} /> : null} + {ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />} + </div> + </div> + </div> + ) +} + +function ReasonNotice({ reason }: { reason: string }) { + return ( + <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"> + {reason} + </div> + ) +} + +function Preparing({ boot }: { boot: DesktopBootState }) { + const progress = Math.max(2, Math.min(100, Math.round(boot.progress))) + const hasError = Boolean(boot.error) + const installing = boot.phase.startsWith('runtime.') + + return ( + <div className="grid gap-3" role="status"> + <p className="text-sm text-muted-foreground"> + {installing + ? 'Hermes is finishing install. This usually takes under a minute on first run.' + : 'Starting Hermes…'} + </p> + <div className="h-2 overflow-hidden rounded-full bg-muted"> + <div + className={cn( + 'h-full rounded-full bg-primary transition-[width] duration-300 ease-out', + hasError && 'bg-destructive' + )} + style={{ width: `${progress}%` }} + /> + </div> + <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground"> + <span className="truncate">{boot.message}</span> + <span>{progress}%</span> + </div> + {hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null} + </div> + ) +} + +function Header() { + return ( + <div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4"> + <div className="flex items-start gap-3"> + <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)"> + <Sparkles className="size-5" /> + </div> + <div> + <h2 className="text-[0.9375rem] font-semibold tracking-tight">Let's get you setup with Hermes Agent</h2> + <p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)"> + Connect a model provider to start chatting. Most options take one click. + </p> + </div> + </div> + </div> + ) +} + +const FEATURED_ID = 'nous' +const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes' +const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1' + +const readShowAll = () => { + try { + return window.localStorage.getItem(SHOW_ALL_KEY) === '1' + } catch { + return false + } +} + +const persistShowAll = (value: boolean) => { + try { + window.localStorage.setItem(SHOW_ALL_KEY, value ? '1' : '0') + } catch { + // localStorage unavailable — degrade silently. + } + + return value +} + +export function Picker({ ctx }: { ctx: OnboardingContext }) { + const { mode, providers } = useStore($desktopOnboarding) + const [showAll, setShowAll] = useState(readShowAll) + const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) + const hasOauth = ordered.length > 0 + + if (mode === 'apikey' || !hasOauth) { + return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} /> + } + + if (providers === null) { + return <Status>Looking up providers...</Status> + } + + const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx) + const featured = ordered.find(p => p.id === FEATURED_ID) ?? null + const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered + // Collapse the secondary providers behind a disclosure only when Nous + // Portal is present to anchor the choice — otherwise show the full list. + const collapsible = Boolean(featured) && rest.length > 0 + const showRest = !collapsible || showAll + + return ( + <div className="grid gap-2"> + {featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null} + {showRest ? ( + <> + {rest.map(p => ( + <ProviderRow key={p.id} onSelect={select} provider={p} /> + ))} + <KeyProviderRow onClick={() => setOnboardingMode('apikey')} /> + </> + ) : null} + {collapsible ? ( + <button + className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground" + onClick={() => setShowAll(persistShowAll(!showAll))} + type="button" + > + {showAll ? 'Collapse' : 'Other providers'} + <ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} /> + </button> + ) : null} + <div className="flex justify-end pt-1"> + <button + className="text-xs font-medium text-muted-foreground hover:text-foreground" + onClick={() => setOnboardingMode('apikey')} + type="button" + > + I have an API key + </button> + </div> + </div> + ) +} + +function FeaturedProviderRow({ + onSelect, + provider +}: { + onSelect: (provider: OAuthProvider) => void + provider: OAuthProvider +}) { + const loggedIn = provider.status?.logged_in + + return ( + <button + className={cn( + 'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10', + loggedIn && 'border-primary' + )} + onClick={() => onSelect(provider)} + type="button" + > + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} /> + <span className="text-base font-semibold">{providerTitle(provider)}</span> + {loggedIn ? ( + <ConnectedTag /> + ) : ( + <span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground"> + <span aria-hidden="true" className="dither inline-block size-2 shrink-0" /> + Recommended + </span> + )} + </div> + <p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p> + </div> + <ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" /> + </button> + ) +} + +function ConnectedTag() { + return ( + <span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"> + <Check className="size-3" /> + Connected + </span> + ) +} + +function KeyProviderRow({ onClick }: { onClick: () => void }) { + return ( + <button + className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40" + onClick={onClick} + type="button" + > + <div className="min-w-0"> + <span className="text-sm font-semibold">OpenRouter</span> + <p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p> + </div> + <ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" /> + </button> + ) +} + +function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) { + const loggedIn = provider.status?.logged_in + const Trail = provider.flow === 'external' ? Terminal : ChevronRight + + return ( + <button + className={cn( + 'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40', + loggedIn && 'border-primary/30' + )} + onClick={() => onSelect(provider)} + type="button" + > + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span className="text-sm font-semibold">{providerTitle(provider)}</span> + {loggedIn ? <ConnectedTag /> : null} + </div> + <p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p> + </div> + <Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" /> + </button> + ) +} + +function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) { + const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0]) + const [value, setValue] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState<null | string>(null) + + const isLocal = option.envKey === 'OPENAI_BASE_URL' + const canSave = value.trim().length >= (isLocal ? 1 : MIN_KEY_LENGTH) + + const submit = async () => { + if (!canSave || saving) { + return + } + + setSaving(true) + setError(null) + const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx) + + if (result.ok) { + setValue('') + } else { + setError(result.message ?? 'Could not save credential.') + } + + setSaving(false) + } + + return ( + <div className="grid gap-4"> + {canGoBack ? ( + <button + className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground" + onClick={() => setOnboardingMode('oauth')} + type="button" + > + <ChevronLeft className="size-3" /> + Back to sign in + </button> + ) : null} + + <div className="grid gap-2 sm:grid-cols-2"> + {API_KEY_OPTIONS.map(o => ( + <button + className={cn( + 'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50', + option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border' + )} + key={o.id} + onClick={() => { + setOption(o) + setValue('') + setError(null) + }} + type="button" + > + <div className="flex items-center justify-between gap-2"> + <span className="text-sm font-medium">{o.name}</span> + {option.id === o.id ? <Check className="size-4 text-primary" /> : null} + </div> + {o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null} + </button> + ))} + </div> + + <div className="grid gap-2"> + <div className="flex items-center justify-between gap-3"> + <p className="text-sm leading-6 text-muted-foreground">{option.description}</p> + {option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null} + </div> + <Input + autoComplete="off" + autoFocus + className="font-mono" + onChange={e => setValue(e.target.value)} + onKeyDown={e => e.key === 'Enter' && void submit()} + placeholder={option.placeholder || 'Paste API key'} + type={isLocal ? 'text' : 'password'} + value={value} + /> + {error ? <p className="text-xs text-destructive">{error}</p> : null} + </div> + + <div className="flex justify-end"> + <Button disabled={!canSave || saving} onClick={() => void submit()}> + {saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />} + {saving ? 'Connecting' : 'Connect'} + </Button> + </div> + </div> + ) +} + +function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) { + const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : '' + + if (flow.status === 'starting') { + return <Status>Starting sign-in for {title}...</Status> + } + + if (flow.status === 'submitting') { + return <Status>Verifying your code with {title}...</Status> + } + + if (flow.status === 'success') { + return ( + <div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary"> + <Check className="size-4" /> + {title} connected. Picking a default model... + </div> + ) + } + + if (flow.status === 'confirming_model') { + return <ConfirmingModelPanel ctx={ctx} flow={flow} /> + } + + if (flow.status === 'error') { + return ( + <div className="grid gap-3"> + <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"> + {flow.message || 'Sign-in failed. Try again.'} + </div> + <div className="flex justify-end"> + <Button onClick={cancelOnboardingFlow} variant="outline"> + Pick a different provider + </Button> + </div> + </div> + ) + } + + if (flow.status === 'awaiting_user') { + return ( + <Step title={`Sign in with ${title}`}> + <ol className="list-decimal space-y-1 pl-5 text-sm text-muted-foreground"> + <li>We opened {title} in your browser.</li> + <li>Authorize Hermes there.</li> + <li>Copy the authorization code and paste it below.</li> + </ol> + <Input + autoFocus + onChange={e => setOnboardingCode(e.target.value)} + onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)} + placeholder="Paste authorization code" + value={flow.code} + /> + <FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open authorization page</DocsLink>}> + <CancelBtn /> + <Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}> + Continue + </Button> + </FlowFooter> + </Step> + ) + } + + if (flow.status === 'external_pending') { + return ( + <Step title={`Sign in with ${title}`}> + <p className="text-sm text-muted-foreground"> + {title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed + in": + </p> + <CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} /> + <FlowFooter + left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null} + > + <CancelBtn /> + <Button onClick={() => void recheckExternalSignin(ctx)}> + <Check className="size-4" /> + I've signed in + </Button> + </FlowFooter> + </Step> + ) + } + + if (flow.status !== 'polling') { + return null + } + + return ( + <Step title={`Sign in with ${title}`}> + <p className="text-sm text-muted-foreground">We opened {title} in your browser. Enter this code there:</p> + <CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} /> + <FlowFooter left={<DocsLink href={flow.start.verification_url}>Re-open verification page</DocsLink>}> + <span className="flex items-center gap-2 text-xs text-muted-foreground"> + <Loader2 className="size-3 animate-spin" /> + Waiting for you to authorize... + </span> + <CancelBtn size="sm" /> + </FlowFooter> + </Step> + ) +} + +function Step({ children, title }: { children: React.ReactNode; title: string }) { + return ( + <div className="grid gap-4"> + <h3 className="text-sm font-semibold">{title}</h3> + {children} + </div> + ) +} + +function CodeBlock({ + copied, + large, + onCopy, + text +}: { + copied: boolean + large?: boolean + onCopy: () => void + text: string +}) { + return ( + <div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3"> + <code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code> + <Button onClick={onCopy} size="sm" variant="outline"> + {copied ? <Check className="size-4" /> : 'Copy'} + </Button> + </div> + ) +} + +function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) { + return ( + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0">{left}</div> + <div className="flex items-center gap-3">{children}</div> + </div> + ) +} + +function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) { + return ( + <Button onClick={cancelOnboardingFlow} size={size} variant="ghost"> + Cancel + </Button> + ) +} + +function ConfirmingModelPanel({ + ctx, + flow +}: { + ctx: OnboardingContext + flow: Extract<OnboardingFlow, { status: 'confirming_model' }> +}) { + // Local state controls whether the model picker dialog is open. + // We reuse the existing ModelPickerDialog component (the same picker + // available from the chat shell) rather than building an inline + // dropdown — gives us search, multi-provider listing if relevant, and + // a familiar UI for users who'll see this picker again later. + const [pickerOpen, setPickerOpen] = useState(false) + + // Pull pricing + tier for the just-picked default so the confirm card + // shows the same $/Mtok + Free/Pro info the picker and CLI do. + const options = useQuery({ + queryKey: ['onboarding-model-options', flow.providerSlug], + queryFn: () => getGlobalModelOptions() + }) + const providerRow = options.data?.providers?.find( + p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase() + ) + const price = providerRow?.pricing?.[flow.currentModel] + const freeTier = providerRow?.free_tier + + return ( + <div className="grid gap-4"> + <div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary"> + <Check className="size-4 shrink-0" /> + <span>{flow.label} connected.</span> + </div> + + <div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p> + {freeTier === true && ( + <span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"> + Free tier + </span> + )} + {freeTier === false && ( + <span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary"> + Pro + </span> + )} + </div> + <p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p> + {price && (price.input || price.output) && ( + <p className="mt-1 font-mono text-xs text-muted-foreground"> + {price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`} + </p> + )} + </div> + <Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline"> + Change + </Button> + </div> + </div> + + <div className="flex justify-end"> + <Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}> + {flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />} + Start chatting + </Button> + </div> + + {/* + ModelPickerDialog defaults to z-130 on its content, which renders + UNDER the onboarding overlay (z-1300) and breaks pointer events. + Bump it above with z-[1310] so the picker sits on top of the + onboarding panel. The dialog's own dim-backdrop layer stays at + its default z-120 — the onboarding overlay is already dimming + the rest of the screen, so we don't want a second backdrop. + */} + <ModelPickerDialog + contentClassName="z-[1310]" + currentModel={flow.currentModel} + currentProvider={flow.providerSlug} + onOpenChange={setPickerOpen} + onSelect={({ model }) => { + void setOnboardingModel(model) + setPickerOpen(false) + }} + open={pickerOpen} + /> + </div> + ) +} + +function DocsLink({ children, href }: { children: React.ReactNode; href: string }) { + return ( + <Button asChild size="xs" variant="ghost"> + <a href={href} rel="noreferrer" target="_blank"> + <ExternalLink className="size-3" /> + {children} + </a> + </Button> + ) +} + +function Status({ children }: { children: React.ReactNode }) { + return ( + <div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + {children} + </div> + ) +} diff --git a/apps/desktop/src/components/haptics-provider.tsx b/apps/desktop/src/components/haptics-provider.tsx new file mode 100644 index 000000000..e86e4428f --- /dev/null +++ b/apps/desktop/src/components/haptics-provider.tsx @@ -0,0 +1,19 @@ +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect } from 'react' +import { useWebHaptics } from 'web-haptics/react' + +import { registerHapticTrigger } from '@/lib/haptics' +import { $hapticsMuted } from '@/store/haptics' + +export function HapticsProvider({ children }: { children: ReactNode }) { + const muted = useStore($hapticsMuted) + const { trigger } = useWebHaptics({ debug: true, showSwitch: false }) + + useEffect(() => { + registerHapticTrigger(muted ? null : trigger) + + return () => registerHapticTrigger(null) + }, [muted, trigger]) + + return <>{children}</> +} diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx new file mode 100644 index 000000000..7c881ba19 --- /dev/null +++ b/apps/desktop/src/components/model-picker.tsx @@ -0,0 +1,327 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' + +import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes' + +import type { HermesGateway } from '../hermes' +import { getGlobalModelOptions } from '../hermes' +import { cn } from '../lib/utils' +import { startManualOnboarding } from '../store/onboarding' + +import { InlineNotice } from './notifications' +import { Button } from './ui/button' +import { Checkbox } from './ui/checkbox' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog' +import { Skeleton } from './ui/skeleton' + +interface ModelPickerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + gw?: HermesGateway + sessionId?: string | null + currentModel: string + currentProvider: string + onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void + /** + * Optional class to apply to DialogContent. Use to override z-index when + * stacking the picker on top of another fixed overlay (e.g. the desktop + * onboarding overlay, which sits at z-1300; the default Dialog z-130 ends + * up rendering underneath and blocks pointer events). + */ + contentClassName?: string +} + +export function ModelPickerDialog({ + open, + onOpenChange, + gw, + sessionId, + currentModel, + currentProvider, + onSelect, + contentClassName +}: ModelPickerDialogProps) { + const [persistGlobal, setPersistGlobal] = useState(!sessionId) + // Own the search term so we can filter manually. cmdk's built-in + // shouldFilter reorders items by its fuzzy-match score (≈alphabetical with + // an empty query), which destroys the backend's curated order. We disable + // it and do a plain substring filter that preserves array order — matching + // the `hermes model` CLI picker, which shows the curated list verbatim. + const [search, setSearch] = useState('') + + const modelOptions = useQuery({ + queryKey: ['model-options', sessionId || 'global'], + queryFn: () => { + if (gw && sessionId) { + return gw.request<ModelOptionsResponse>('model.options', { + session_id: sessionId + }) + } + + return getGlobalModelOptions() + }, + enabled: open + }) + + const providers = modelOptions.data?.providers ?? [] + const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') + const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') + const loading = modelOptions.isPending && !modelOptions.data + + const error = modelOptions.error + ? modelOptions.error instanceof Error + ? modelOptions.error.message + : String(modelOptions.error) + : null + + const selectModel = (provider: ModelOptionProvider, model: string) => { + onSelect({ + provider: provider.slug, + model, + persistGlobal: persistGlobal || !sessionId + }) + onOpenChange(false) + } + + // Open the full onboarding provider selector to add/switch a provider. + // Reuses the entire onboarding flow (OAuth rows, API-key form, device-code, + // model-confirm) instead of duplicating provider UI here. Closes the picker + // so the onboarding overlay (z-1300) isn't rendered underneath it. + const addProvider = () => { + startManualOnboarding() + onOpenChange(false) + } + + return ( + <Dialog onOpenChange={onOpenChange} open={open}> + <DialogContent className={cn('max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0', contentClassName)}> + <DialogHeader className="border-b border-border px-4 py-3"> + <DialogTitle>Switch model</DialogTitle> + <DialogDescription className="font-mono text-xs leading-relaxed"> + current: {optionsModel || currentModel || '(unknown)'} + {optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''} + </DialogDescription> + </DialogHeader> + + <Command className="rounded-none bg-card" shouldFilter={false}> + <CommandInput + autoFocus + onValueChange={setSearch} + placeholder="Filter providers and models..." + value={search} + /> + <CommandList className="max-h-96"> + {!loading && !error && <CommandEmpty>No models found.</CommandEmpty>} + <ModelResults + currentModel={optionsModel || currentModel} + currentProvider={optionsProvider || currentProvider} + error={error} + loading={loading} + onSelectModel={selectModel} + providers={providers} + search={search} + /> + </CommandList> + </Command> + + <DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between"> + <label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground"> + <Checkbox + checked={persistGlobal || !sessionId} + disabled={!sessionId} + onCheckedChange={checked => setPersistGlobal(checked === true)} + /> + {sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'} + </label> + + <div className="flex items-center gap-2"> + <Button onClick={addProvider} variant="ghost"> + Add provider + </Button> + <Button onClick={() => onOpenChange(false)} variant="outline"> + Cancel + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +function ModelResults({ + loading, + error, + providers, + currentModel, + currentProvider, + onSelectModel, + search +}: { + loading: boolean + error: string | null + providers: ModelOptionProvider[] + currentModel: string + currentProvider: string + onSelectModel: (provider: ModelOptionProvider, model: string) => void + search: string +}) { + if (loading) { + return <LoadingResults /> + } + + if (error) { + return ( + <div className="px-3 py-3"> + <InlineNotice kind="error" title="Could not load models"> + {error} + </InlineNotice> + </div> + ) + } + + if (providers.length === 0) { + return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div> + } + + const q = search.trim().toLowerCase() + const matches = (provider: ModelOptionProvider, model: string) => + !q || + model.toLowerCase().includes(q) || + provider.name.toLowerCase().includes(q) || + provider.slug.toLowerCase().includes(q) + + // Only configured providers (those with curated models) are selectable + // here. Switching to a NOT-yet-configured provider goes through the + // "Add provider" footer button, which opens the full onboarding selector. + const configured = providers.filter(p => (p.models ?? []).length > 0) + + return ( + <> + {configured.map(provider => { + // Preserve the backend's curated order — filter in place, no re-sort. + const models = (provider.models ?? []).filter(m => matches(provider, m)) + + if (models.length === 0) { + return null + } + + const unavailable = new Set(provider.unavailable_models ?? []) + + return ( + <CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}> + {provider.warning && ( + <div className="px-2 pb-2"> + <InlineNotice className="px-2.5 py-1.5 text-xs" kind="warning"> + {provider.warning} + </InlineNotice> + </div> + )} + {models.map(model => { + const isCurrent = model === currentModel && provider.slug === currentProvider + const price = provider.pricing?.[model] + const locked = unavailable.has(model) + + return ( + <CommandItem + className={cn( + 'flex items-center gap-2 pl-6 font-mono', + isCurrent && + 'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground', + locked && 'cursor-not-allowed opacity-45' + )} + disabled={locked} + key={`${provider.slug}:${model}`} + onSelect={() => { + if (!locked) { + onSelectModel(provider, model) + } + }} + value={`${provider.slug}:${model}`} + > + <span className="min-w-0 flex-1 truncate">{model}</span> + {locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>} + <ModelPrice isCurrent={isCurrent} price={price} /> + </CommandItem> + ) + })} + {unavailable.size > 0 && ( + <div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground"> + Pro models need a paid Nous subscription. + </div> + )} + </CommandGroup> + ) + })} + </> + ) +} + +// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns. +// Renders nothing when pricing is unavailable for the model. +function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) { + if (!price || (!price.input && !price.output)) { + return null + } + + if (price.free) { + return ( + <span + className={cn( + 'shrink-0 rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide', + isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' + )} + > + Free + </span> + ) + } + + return ( + <span + className={cn( + 'shrink-0 text-[0.66rem] tabular-nums', + isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground' + )} + title="Input / Output price per million tokens" + > + {price.input || '?'} / {price.output || '?'} + </span> + ) +} + +function LoadingResults() { + return ( + <CommandGroup heading={<Skeleton className="h-3 w-32" />}> + {Array.from({ length: 4 }, (_, rowIndex) => ( + <div className="rounded-sm py-1.5 pl-6 pr-2" key={rowIndex}> + <Skeleton className={cn('h-5', rowIndex % 3 === 0 ? 'w-3/5' : rowIndex % 3 === 1 ? 'w-4/5' : 'w-1/2')} /> + </div> + ))} + </CommandGroup> + ) +} + +function ProviderHeading({ provider }: { provider: ModelOptionProvider }) { + // free_tier is only set for Nous. true → "Free tier", false → "Pro". + const tierBadge = + provider.free_tier === true ? ( + <span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"> + Free tier + </span> + ) : provider.free_tier === false ? ( + <span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary"> + Pro + </span> + ) : null + + return ( + <span className="flex min-w-0 items-center gap-2"> + <span className="truncate">{provider.name}</span> + <span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground"> + {provider.slug} · {provider.total_models ?? provider.models?.length ?? 0} + </span> + {tierBadge} + </span> + ) +} diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx new file mode 100644 index 000000000..af1d9a96e --- /dev/null +++ b/apps/desktop/src/components/notifications.tsx @@ -0,0 +1,178 @@ +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect, useRef, useState } from 'react' + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Codicon } from '@/components/ui/codicon' +import { CopyButton } from '@/components/ui/copy-button' +import { triggerHaptic } from '@/lib/haptics' +import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $notifications, + type AppNotification, + clearNotifications, + dismissNotification, + type NotificationKind +} from '@/store/notifications' + +type ToneVariant = 'default' | 'destructive' | 'warning' | 'success' + +const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; variant: ToneVariant }> = { + error: { icon: AlertCircle, iconClass: 'text-destructive', variant: 'destructive' }, + warning: { icon: AlertTriangle, iconClass: 'text-primary', variant: 'warning' }, + info: { icon: Info, iconClass: 'text-muted-foreground', variant: 'default' }, + success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' } +} + +const STACK_SURFACE = 'pointer-events-auto border-border/80 bg-popover/95 shadow-lg shadow-black/5 backdrop-blur-md' +const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground' + +export function NotificationStack() { + const notifications = useStore($notifications) + const lastNotificationIdRef = useRef<string | null>(null) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + if (notifications.length <= 1) { + setExpanded(false) + } + }, [notifications.length]) + + useEffect(() => { + const latest = notifications[0] + + if (!latest || latest.id === lastNotificationIdRef.current) { + return + } + + lastNotificationIdRef.current = latest.id + + if (latest.kind === 'success') { + triggerHaptic('success') + } else if (latest.kind === 'error') { + triggerHaptic('error') + } else if (latest.kind === 'warning') { + triggerHaptic('warning') + } + }, [notifications]) + + if (notifications.length === 0) { + return null + } + + const [latest, ...olderNotifications] = notifications + const overflowCount = olderNotifications.length + + return ( + <div + aria-label="Notifications" + className="pointer-events-none absolute left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2" + role="region" + > + <NotificationItem notification={latest} /> + {expanded && olderNotifications.map(n => <NotificationItem key={n.id} notification={n} />)} + {overflowCount > 0 && ( + <div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}> + <button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button"> + {expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'} + </button> + <button className={GHOST_BTN} onClick={clearNotifications} type="button"> + Clear all + </button> + </div> + )} + </div> + ) +} + +function NotificationItem({ notification }: { notification: AppNotification }) { + const styles = tone[notification.kind] + const Icon = styles.icon + const hasDetail = Boolean(notification.detail && notification.detail !== notification.message) + + return ( + <Alert + aria-live={notification.kind === 'error' ? 'assertive' : 'polite'} + className={cn(STACK_SURFACE, 'grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5')} + role={notification.kind === 'error' ? 'alert' : 'status'} + variant="default" + > + <Icon className={styles.iconClass} /> + <div className="col-start-2 min-w-0"> + {notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>} + <AlertDescription className="col-start-auto"> + <p className="m-0">{notification.message}</p> + {hasDetail && <NotificationDetail detail={notification.detail || ''} />} + {notification.action && ( + <button + className="mt-1.5 inline-flex items-center rounded-md bg-primary/15 px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/25" + onClick={() => { + notification.action?.onClick() + dismissNotification(notification.id) + }} + type="button" + > + {notification.action.label} + </button> + )} + </AlertDescription> + </div> + <button + aria-label="Dismiss notification" + className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + onClick={() => dismissNotification(notification.id)} + type="button" + > + <Codicon name="close" size="0.875rem" /> + </button> + </Alert> + ) +} + +function NotificationDetail({ detail }: { detail: string }) { + return ( + <details className="mt-2 text-xs text-muted-foreground"> + <summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground"> + Details + </summary> + <div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2"> + <pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed"> + {detail} + </pre> + <CopyButton + appearance="inline" + className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground" + errorMessage="Could not copy notification detail" + iconClassName="size-3" + label="Copy detail" + text={detail} + > + Copy detail + </CopyButton> + </div> + </details> + ) +} + +export function InlineNotice({ + kind = 'info', + title, + children, + className +}: { + kind?: NotificationKind + title?: string + children: ReactNode + className?: string +}) { + const styles = tone[kind] + const Icon = styles.icon + + return ( + <Alert className={cn('min-w-0', className)} role={kind === 'error' ? 'alert' : 'status'} variant={styles.variant}> + <Icon /> + {title && <AlertTitle>{title}</AlertTitle>} + <AlertDescription className={cn(!title && 'row-start-1')}>{children}</AlertDescription> + </Alert> + ) +} diff --git a/apps/desktop/src/components/page-loader.tsx b/apps/desktop/src/components/page-loader.tsx new file mode 100644 index 000000000..3589c6349 --- /dev/null +++ b/apps/desktop/src/components/page-loader.tsx @@ -0,0 +1,34 @@ +import type { ComponentProps } from 'react' + +import { Loader } from '@/components/ui/loader' +import { cn } from '@/lib/utils' + +interface PageLoaderProps extends Omit<ComponentProps<'div'>, 'children'> { + label?: string +} + +export function PageLoader({ + 'aria-label': ariaLabel, + className, + label = 'Loading', + role = 'status', + ...props +}: PageLoaderProps) { + return ( + <div + {...props} + aria-label={ariaLabel ?? label} + className={cn('grid h-full place-items-center', className)} + role={role} + > + <Loader + aria-hidden="true" + className="size-10 text-primary/70" + pathSteps={220} + role="presentation" + strokeScale={0.72} + type="rose-curve" + /> + </div> + ) +} diff --git a/apps/desktop/src/components/pane-shell/context.ts b/apps/desktop/src/components/pane-shell/context.ts new file mode 100644 index 000000000..2fa3738a7 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/context.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react' + +export interface PaneSlot { + column: number + side: 'left' | 'right' + open: boolean +} + +export interface PaneShellContextValue { + paneById: Map<string, PaneSlot> + mainColumn: number +} + +export const PaneShellContext = createContext<PaneShellContextValue | null>(null) diff --git a/apps/desktop/src/components/pane-shell/index.ts b/apps/desktop/src/components/pane-shell/index.ts new file mode 100644 index 000000000..40946890c --- /dev/null +++ b/apps/desktop/src/components/pane-shell/index.ts @@ -0,0 +1,4 @@ +export type { PaneShellContextValue, PaneSlot } from './context' +export { PaneShellContext } from './context' +export { Pane, PaneMain, PaneShell } from './pane-shell' +export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell' diff --git a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx new file mode 100644 index 000000000..99f481f05 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx @@ -0,0 +1,333 @@ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes' + +import { Pane, PaneMain, PaneShell } from './pane-shell' + +function gridContainer(rendered: ReturnType<typeof render>): HTMLElement { + const root = rendered.container.firstElementChild + + if (!(root instanceof HTMLElement)) { + throw new Error('PaneShell did not render a root element') + } + + return root +} + +function getColumnTemplate(container: HTMLElement): string[] { + return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean) +} + +function mockWidth(element: HTMLElement, width: number) { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 0, + height: 0, + left: 0, + right: width, + top: 0, + width, + x: 0, + y: 0, + toJSON: () => ({}) + }) + }) +} + +describe('PaneShell composition', () => { + beforeEach(() => { + $paneStates.set({}) + window.localStorage.clear() + }) + + afterEach(() => { + cleanup() + $paneStates.set({}) + window.localStorage.clear() + }) + + it('builds a 2-column grid for one left pane + main', () => { + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + const tracks = getColumnTemplate(gridContainer(rendered)) + + expect(tracks).toEqual(['240px', 'minmax(0,1fr)']) + }) + + it('orders panes left-to-right by side, preserving source order within a side', () => { + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + files + </Pane> + <Pane id="sessions" side="left" width="200px"> + sessions + </Pane> + <PaneMain>main</PaneMain> + <Pane id="preview" side="right" width="320px"> + preview + </Pane> + <Pane id="inspector" side="right" width="280px"> + inspector + </Pane> + </PaneShell> + ) + + const tracks = getColumnTemplate(gridContainer(rendered)) + + expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px']) + }) + + it('collapses a closed pane to 0px', () => { + const rendered = render( + <PaneShell> + <Pane defaultOpen={false} id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + const tracks = getColumnTemplate(gridContainer(rendered)) + + expect(tracks).toEqual(['0px', 'minmax(0,1fr)']) + }) + + it('reads open state from the panes store', () => { + setPaneOpen('files', false) + + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) + }) + + it('disabled forces the track to 0px even when the store says open', () => { + setPaneOpen('files', true) + + const rendered = render( + <PaneShell> + <Pane disabled={true} id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) + }) + + it('disabled does NOT mutate the store-persisted open state', () => { + setPaneOpen('files', true) + + render( + <PaneShell> + <Pane disabled={true} id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + expect($paneStates.get().files?.open).toBe(true) + }) + + it('uses widthOverride from the store when set', () => { + setPaneOpen('files', true) + setPaneWidthOverride('files', 320) + + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)']) + }) + + it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => { + const rendered = render( + <PaneShell> + <Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)"> + inspector + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + const template = gridContainer(rendered).style.gridTemplateColumns + + expect(template).toContain('clamp(13.5rem,21vw,20rem)') + }) + + it('coerces numeric widths to px', () => { + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width={224}> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)']) + }) + + it('emits per-pane width as a CSS variable', () => { + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + const root = gridContainer(rendered) + + expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px') + }) + + it('places a Pane in the correct grid column via inline style', () => { + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + <span data-testid="files-content">files</span> + </Pane> + <PaneMain> + <span data-testid="main-content">main</span> + </PaneMain> + <Pane id="preview" side="right" width="320px"> + <span data-testid="preview-content">preview</span> + </Pane> + </PaneShell> + ) + + const filesCell = rendered.getByTestId('files-content').parentElement! + const mainCell = rendered.getByTestId('main-content').parentElement! + const previewCell = rendered.getByTestId('preview-content').parentElement! + + expect(filesCell.style.gridColumn).toBe('1 / 2') + expect(mainCell.style.gridColumn).toBe('2 / 3') + expect(previewCell.style.gridColumn).toBe('3 / 4') + }) + + it('marks closed panes aria-hidden', () => { + const rendered = render( + <PaneShell> + <Pane defaultOpen={false} id="files" side="left" width="240px"> + <span data-testid="files-content">files</span> + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + const cell = rendered.getByTestId('files-content').parentElement! + + expect(cell.getAttribute('aria-hidden')).toBe('true') + expect(cell.getAttribute('data-pane-open')).toBe('false') + }) + + it('passes through arbitrary non-Pane children for self-placement', () => { + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + files + </Pane> + <PaneMain>main</PaneMain> + <div data-testid="floating-overlay" style={{ position: 'absolute' }}> + overlay + </div> + </PaneShell> + ) + + expect(rendered.getByTestId('floating-overlay')).toBeDefined() + }) + + it('shows a resize handle only when resizable', () => { + const rendered = render( + <PaneShell> + <Pane id="files" side="left" width="240px"> + files + </Pane> + <Pane id="preview" resizable side="right" width="320px"> + preview + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + expect(rendered.queryByLabelText('Resize files')).toBeNull() + expect(rendered.getByLabelText('Resize preview')).toBeDefined() + }) + + it('dragging a left-pane separator stores a wider width override', () => { + const rendered = render( + <PaneShell> + <Pane id="files" maxWidth={360} minWidth={200} resizable side="left" width="240px"> + <span data-testid="files-content">files</span> + </Pane> + <PaneMain>main</PaneMain> + </PaneShell> + ) + + const paneCell = rendered.getByTestId('files-content').parentElement + + if (!(paneCell instanceof HTMLElement)) { + throw new Error('Expected pane cell element') + } + + mockWidth(paneCell, 240) + const separator = rendered.getByLabelText('Resize files') + + fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 }) + fireEvent.pointerMove(window, { clientX: 300 }) + fireEvent.pointerUp(window, { clientX: 300 }) + + expect($paneStates.get().files?.widthOverride).toBe(300) + }) + + it('dragging a right-pane separator clamps to max width', () => { + const rendered = render( + <PaneShell> + <PaneMain>main</PaneMain> + <Pane id="preview" maxWidth={340} minWidth={220} resizable side="right" width="320px"> + <span data-testid="preview-content">preview</span> + </Pane> + </PaneShell> + ) + + const paneCell = rendered.getByTestId('preview-content').parentElement + + if (!(paneCell instanceof HTMLElement)) { + throw new Error('Expected pane cell element') + } + + mockWidth(paneCell, 320) + const separator = rendered.getByLabelText('Resize preview') + + fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 }) + fireEvent.pointerMove(window, { clientX: 760 }) + fireEvent.pointerUp(window, { clientX: 760 }) + + expect($paneStates.get().preview?.widthOverride).toBe(340) + }) +}) diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx new file mode 100644 index 000000000..a3f6719ee --- /dev/null +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -0,0 +1,330 @@ +import { useStore } from '@nanostores/react' +import { + Children, + type CSSProperties, + isValidElement, + type ReactElement, + type ReactNode, + type PointerEvent as ReactPointerEvent, + useCallback, + useContext, + useEffect, + useMemo, + useRef +} from 'react' + +import { cn } from '@/lib/utils' +import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' + +import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' + +type PaneSide = 'left' | 'right' +type WidthValue = string | number + +interface PaneRoleMarker { + __paneShellRole?: 'pane' | 'main' +} + +export interface PaneProps { + children?: ReactNode + className?: string + defaultOpen?: boolean + /** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */ + disabled?: boolean + id: string + maxWidth?: WidthValue + minWidth?: WidthValue + resizable?: boolean + side: PaneSide + width?: WidthValue +} + +export interface PaneMainProps { + children?: ReactNode + className?: string +} + +export interface PaneShellProps { + children?: ReactNode + className?: string + style?: CSSProperties +} + +interface CollectedPane { + defaultOpen: boolean + disabled: boolean + id: string + resizable: boolean + side: PaneSide + width: string +} + +const DEFAULT_WIDTH = '16rem' +const DEFAULT_RESIZE_MIN_WIDTH = 160 + +const widthToCss = (value: WidthValue | undefined, fallback: string) => + value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value + +const remPx = () => + typeof window === 'undefined' + ? 16 + : Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16 + +// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping. +function widthToPx(value: WidthValue | undefined) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined + } + + const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/) + + if (!match) { + return undefined + } + + return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1) +} + +function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement { + return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role +} + +function collectPanes(children: ReactNode) { + const left: CollectedPane[] = [] + const right: CollectedPane[] = [] + let mainCount = 0 + + Children.forEach(children, child => { + if (isRole(child, 'main')) { + mainCount++ + + return + } + + if (!isRole(child, 'pane')) { + return + } + + const props = child.props as PaneProps + + const entry: CollectedPane = { + defaultOpen: props.defaultOpen ?? true, + disabled: props.disabled ?? false, + id: props.id, + resizable: props.resizable ?? false, + side: props.side, + width: widthToCss(props.width, DEFAULT_WIDTH) + } + + ;(props.side === 'left' ? left : right).push(entry) + }) + + return { left, mainCount, right } +} + +function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) { + const stateOpen = states[pane.id]?.open ?? pane.defaultOpen + const open = !pane.disabled && stateOpen + + if (!open) { + return { open: false, track: '0px' } + } + + const override = pane.resizable ? states[pane.id]?.widthOverride : undefined + + return { open: true, track: override !== undefined ? `${override}px` : pane.width } +} + +export function PaneShell({ children, className, style }: PaneShellProps) { + const paneStates = useStore($paneStates) + const { left, mainCount, right } = useMemo(() => collectPanes(children), [children]) + + if (import.meta.env.DEV && mainCount > 1) { + console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount) + } + + const ctxValue = useMemo(() => { + const paneById = new Map<string, PaneSlot>() + const tracks: string[] = [] + const cssVars: Record<string, string> = {} + let column = 1 + + for (const pane of left) { + const { open, track } = trackForPane(pane, paneStates) + tracks.push(track) + paneById.set(pane.id, { column, open, side: 'left' }) + cssVars[`--pane-${pane.id}-width`] = track + column++ + } + + tracks.push('minmax(0,1fr)') + const mainColumn = column++ + + for (const pane of right) { + const { open, track } = trackForPane(pane, paneStates) + tracks.push(track) + paneById.set(pane.id, { column, open, side: 'right' }) + cssVars[`--pane-${pane.id}-width`] = track + column++ + } + + return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & { + cssVars: Record<string, string> + gridTemplate: string + } + }, [left, paneStates, right]) + + const composedStyle = useMemo<CSSProperties>( + () => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }), + [ctxValue.cssVars, ctxValue.gridTemplate, style] + ) + + return ( + <PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}> + <div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}> + {children} + </div> + </PaneShellContext.Provider> + ) +} + +export function Pane({ + children, + className, + defaultOpen = true, + disabled = false, + id, + maxWidth, + minWidth, + resizable = false +}: PaneProps) { + const ctx = useContext(PaneShellContext) + const registered = useRef(false) + const paneRef = useRef<HTMLDivElement | null>(null) + + useEffect(() => { + if (registered.current) { + return + } + + registered.current = true + ensurePaneRegistered(id, { open: defaultOpen }) + }, [defaultOpen, id]) + + const slot = ctx?.paneById.get(id) + const open = Boolean(slot?.open && !disabled) + const canResize = open && resizable + const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH + const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY + const side = slot?.side ?? 'left' + + const startResize = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0 + + if (!canResize || paneWidth <= 0) { + return + } + + event.preventDefault() + + const handle = event.currentTarget + const { pointerId, clientX: startX } = event + const dir = side === 'left' ? 1 : -1 + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + + handle.setPointerCapture?.(pointerId) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + const onMove = (e: PointerEvent) => { + const next = paneWidth + (e.clientX - startX) * dir + setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next)))) + } + + const cleanup = () => { + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + handle.releasePointerCapture?.(pointerId) + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', cleanup, true) + window.removeEventListener('pointercancel', cleanup, true) + window.removeEventListener('blur', cleanup) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', cleanup, true) + window.addEventListener('pointercancel', cleanup, true) + window.addEventListener('blur', cleanup) + }, + [canResize, hi, id, lo, side] + ) + + if (!ctx) { + if (import.meta.env.DEV) { + console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`) + } + + return null + } + + if (!slot) { + return null + } + + return ( + <div + aria-hidden={!open} + className={cn('relative row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)} + data-pane-id={id} + data-pane-open={open ? 'true' : 'false'} + data-pane-side={slot.side} + ref={paneRef} + style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }} + > + {canResize && ( + <div + aria-label={`Resize ${id}`} + aria-orientation="vertical" + className={cn( + 'group absolute bottom-0 top-0 z-20 w-1 cursor-col-resize [-webkit-app-region:no-drag]', + slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2' + )} + onPointerDown={startResize} + role="separator" + tabIndex={0} + > + <span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" /> + </div> + )} + {children} + </div> + ) +} + +;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane' + +export function PaneMain({ children, className }: PaneMainProps) { + const ctx = useContext(PaneShellContext) + + if (!ctx) { + if (import.meta.env.DEV) { + console.warn('[PaneMain] must be rendered inside <PaneShell>') + } + + return null + } + + return ( + <div + className={cn('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)} + data-pane-main="true" + style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }} + > + {children} + </div> + ) +} + +;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main' diff --git a/apps/desktop/src/components/status-dot.tsx b/apps/desktop/src/components/status-dot.tsx new file mode 100644 index 000000000..3b9c20d36 --- /dev/null +++ b/apps/desktop/src/components/status-dot.tsx @@ -0,0 +1,26 @@ +import type { ComponentProps } from 'react' + +import { cn } from '@/lib/utils' + +export type StatusTone = 'good' | 'muted' | 'warn' | 'bad' + +const TONE_BG: Record<StatusTone, string> = { + good: 'bg-primary', + muted: 'bg-muted-foreground/40', + warn: 'bg-amber-500', + bad: 'bg-destructive' +} + +interface StatusDotProps extends ComponentProps<'span'> { + tone: StatusTone +} + +export function StatusDot({ className, tone, ...props }: StatusDotProps) { + return ( + <span + aria-hidden="true" + className={cn('inline-block size-1.5 rounded-full', TONE_BG[tone], className)} + {...props} + /> + ) +} diff --git a/apps/desktop/src/components/ui/alert.tsx b/apps/desktop/src/components/ui/alert.tsx new file mode 100644 index 000000000..d8d8004df --- /dev/null +++ b/apps/desktop/src/components/ui/alert.tsx @@ -0,0 +1,53 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative grid w-full grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3 gap-y-1 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground shadow-xs [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0', + { + variants: { + variant: { + default: 'border-border', + destructive: + 'border-destructive/35 bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-destructive)_4%)] [&>svg]:text-destructive', + warning: + 'border-primary/30 bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-primary)_4%)] [&>svg]:text-primary', + success: + 'border-primary/25 bg-[color-mix(in_srgb,var(--dt-card)_97%,var(--dt-primary)_3%)] [&>svg]:text-primary' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) { + return <div className={cn(alertVariants({ variant }), className)} data-slot="alert" role="alert" {...props} /> +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight text-foreground', className)} + data-slot="alert-title" + {...props} + /> + ) +} + +function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn( + 'col-start-2 grid justify-items-start gap-1 text-muted-foreground [&_p]:leading-relaxed', + className + )} + data-slot="alert-description" + {...props} + /> + ) +} + +export { Alert, AlertDescription, AlertTitle } diff --git a/apps/desktop/src/components/ui/braille-spinner.tsx b/apps/desktop/src/components/ui/braille-spinner.tsx new file mode 100644 index 000000000..3b6b8985c --- /dev/null +++ b/apps/desktop/src/components/ui/braille-spinner.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' +import spinners, { type BrailleSpinnerName } from 'unicode-animations' + +import { cn } from '@/lib/utils' + +interface NormalisedSpinner { + frames: readonly string[] + interval: number +} + +// Some spinners ship multi-character frames. Pull the first cell so each +// frame fits in one monospace box — matches how the TUI uses them. +const FRAMES_BY_NAME: Record<BrailleSpinnerName, NormalisedSpinner> = (() => { + const out = {} as Record<BrailleSpinnerName, NormalisedSpinner> + + for (const name of Object.keys(spinners) as BrailleSpinnerName[]) { + const raw = spinners[name] + + out[name] = { + frames: raw.frames.map(frame => [...frame][0] ?? '⠀'), + interval: raw.interval + } + } + + return out +})() + +interface BrailleSpinnerProps { + ariaLabel?: string + className?: string + spinner?: BrailleSpinnerName +} + +/** + * One-char braille spinner driven by `unicode-animations`. Mirrors the + * spinner used by the Ink TUI so the desktop and terminal experiences + * read the same visually. Renders inside an `inline-flex` cell with + * `leading-none` and `items-center` so it sits vertically centred inside + * its parent's line-box (e.g. the 1.1rem disclosure row). + */ +export function BrailleSpinner({ ariaLabel = 'Loading', className, spinner = 'breathe' }: BrailleSpinnerProps) { + const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.breathe! + const [frame, setFrame] = useState(0) + + useEffect(() => { + setFrame(0) + const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) + + return () => window.clearInterval(id) + }, [spin]) + + return ( + <span + aria-label={ariaLabel} + className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)} + role="status" + > + {spin.frames[frame]} + </span> + ) +} diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx new file mode 100644 index 000000000..467f4c0a5 --- /dev/null +++ b/apps/desktop/src/components/ui/button.tsx @@ -0,0 +1,62 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { Slot } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 decoration-current/20 hover:underline' + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8', + 'icon-lg': 'size-10' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +) + +function Button({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : 'button' + + return ( + <Comp + className={cn(buttonVariants({ variant, size }), className)} + data-size={size} + data-slot="button" + data-variant={variant} + {...props} + /> + ) +} + +export { Button, buttonVariants } diff --git a/apps/desktop/src/components/ui/checkbox.tsx b/apps/desktop/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..2e6b24256 --- /dev/null +++ b/apps/desktop/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import { Checkbox as CheckboxPrimitive } from 'radix-ui' +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { + return ( + <CheckboxPrimitive.Root + className={cn( + 'peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', + className + )} + data-slot="checkbox" + {...props} + > + <CheckboxPrimitive.Indicator + className="flex items-center justify-center text-current" + data-slot="checkbox-indicator" + > + <Codicon name="check" size="0.875rem" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> + ) +} + +export { Checkbox } diff --git a/apps/desktop/src/components/ui/codicon.tsx b/apps/desktop/src/components/ui/codicon.tsx new file mode 100644 index 000000000..b07921688 --- /dev/null +++ b/apps/desktop/src/components/ui/codicon.tsx @@ -0,0 +1,20 @@ +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +export interface CodiconProps extends React.HTMLAttributes<HTMLElement> { + name: string + size?: number | string + spinning?: boolean +} + +export function Codicon({ className, name, size, spinning, style, ...props }: CodiconProps) { + return ( + <i + aria-hidden="true" + className={cn('codicon', `codicon-${name}`, spinning && 'codicon-modifier-spin', className)} + style={{ fontSize: size, ...style }} + {...props} + /> + ) +} diff --git a/apps/desktop/src/components/ui/command.tsx b/apps/desktop/src/components/ui/command.tsx new file mode 100644 index 000000000..dbbc655d6 --- /dev/null +++ b/apps/desktop/src/components/ui/command.tsx @@ -0,0 +1,111 @@ +import { Command as CommandPrimitive } from 'cmdk' +import * as React from 'react' + +import { SearchIcon } from '@/lib/icons' +import { cn } from '@/lib/utils' + +function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) { + return ( + <CommandPrimitive + className={cn( + 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', + className + )} + data-slot="command" + {...props} + /> + ) +} + +function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) { + return ( + <div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper"> + <SearchIcon className="size-4 shrink-0 text-muted-foreground" /> + <CommandPrimitive.Input + className={cn( + 'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', + className + )} + data-slot="command-input" + {...props} + /> + </div> + ) +} + +function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) { + return ( + <CommandPrimitive.List + className={cn('max-h-100 overflow-y-auto overflow-x-hidden', className)} + data-slot="command-list" + {...props} + /> + ) +} + +function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) { + return ( + <CommandPrimitive.Empty + className="py-6 text-center text-sm text-muted-foreground" + data-slot="command-empty" + {...props} + /> + ) +} + +function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) { + return ( + <CommandPrimitive.Group + className={cn( + 'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:sticky **:[[cmdk-group-heading]]:top-0 **:[[cmdk-group-heading]]:z-10 **:[[cmdk-group-heading]]:bg-popover **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground', + className + )} + data-slot="command-group" + {...props} + /> + ) +} + +function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) { + return ( + <CommandPrimitive.Separator + className={cn('-mx-1 h-px bg-border', className)} + data-slot="command-separator" + {...props} + /> + ) +} + +function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) { + return ( + <CommandPrimitive.Item + className={cn( + 'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50', + className + )} + data-slot="command-item" + {...props} + /> + ) +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + <span + className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} + data-slot="command-shortcut" + {...props} + /> + ) +} + +export { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut +} diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..0849efdd5 --- /dev/null +++ b/apps/desktop/src/components/ui/context-menu.tsx @@ -0,0 +1,141 @@ +import { ContextMenu as ContextMenuPrimitive } from 'radix-ui' +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { + return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} /> +} + +function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) { + return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> +} + +function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) { + return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} /> +} + +function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) { + return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> +} + +function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) { + return ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + className={cn( + 'z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + data-slot="context-menu-content" + {...props} + /> + </ContextMenuPrimitive.Portal> + ) +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + <ContextMenuPrimitive.Item + className={cn( + "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!", + className + )} + data-inset={inset} + data-slot="context-menu-item" + data-variant={variant} + {...props} + /> + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & { + inset?: boolean +}) { + return ( + <ContextMenuPrimitive.Label + className={cn('px-2 py-1 text-xs font-medium text-(--ui-text-tertiary) data-[inset]:pl-7', className)} + data-inset={inset} + data-slot="context-menu-label" + {...props} + /> + ) +} + +function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) { + return ( + <ContextMenuPrimitive.Separator + className={cn('-mx-1 my-1 h-px bg-(--ui-stroke-tertiary)', className)} + data-slot="context-menu-separator" + {...props} + /> + ) +} + +function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) { + return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} /> +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { + inset?: boolean +}) { + return ( + <ContextMenuPrimitive.SubTrigger + className={cn( + "flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)", + className + )} + data-inset={inset} + data-slot="context-menu-sub-trigger" + {...props} + > + {children} + <Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" /> + </ContextMenuPrimitive.SubTrigger> + ) +} + +function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) { + return ( + <ContextMenuPrimitive.SubContent + className={cn( + 'z-50 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + data-slot="context-menu-sub-content" + {...props} + /> + ) +} + +export { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +} diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx new file mode 100644 index 000000000..a28f1c703 --- /dev/null +++ b/apps/desktop/src/components/ui/copy-button.tsx @@ -0,0 +1,221 @@ +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { triggerHaptic } from '@/lib/haptics' +import { Check, Copy, X } from '@/lib/icons' +import { cn } from '@/lib/utils' + +type CopyPayload = string | (() => Promise<string> | string) +type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row' +type CopyStatus = 'copied' | 'error' | 'idle' +const COPIED_RESET_MS = 1_500 + +export async function writeClipboardText(text: string) { + if (!text) { + return + } + + if (window.hermesDesktop?.writeClipboard) { + await window.hermesDesktop.writeClipboard(text) + + return + } + + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + + return + } + + throw new Error('Clipboard API is unavailable') +} + +export interface CopyButtonProps { + appearance?: CopyButtonAppearance + buttonSize?: React.ComponentProps<typeof Button>['size'] + buttonVariant?: React.ComponentProps<typeof Button>['variant'] + children?: React.ReactNode + className?: string + disabled?: boolean + errorMessage?: string + haptic?: boolean + iconClassName?: string + label?: string + onCopied?: () => void + onCopyError?: (error: unknown) => void + preventDefault?: boolean + showLabel?: boolean + stopPropagation?: boolean + text: CopyPayload + title?: string +} + +export function CopyButton({ + appearance = 'button', + buttonSize, + buttonVariant = 'ghost', + children, + className, + disabled = false, + errorMessage = 'Copy failed', + haptic = true, + iconClassName, + label = 'Copy', + onCopied, + onCopyError, + preventDefault = false, + showLabel, + stopPropagation = false, + text, + title +}: CopyButtonProps) { + const [status, setStatus] = React.useState<CopyStatus>('idle') + const resetRef = React.useRef<number | null>(null) + + React.useEffect(() => { + return () => { + if (resetRef.current !== null) { + window.clearTimeout(resetRef.current) + } + } + }, []) + + const copy = React.useCallback( + async (event?: Event | React.MouseEvent<HTMLElement>) => { + if (preventDefault) { + event?.preventDefault() + } + + if (stopPropagation) { + event?.stopPropagation() + } + + try { + const value = typeof text === 'function' ? await text() : text + + if (!value) { + return + } + + await writeClipboardText(value) + + if (haptic) { + triggerHaptic('selection') + } + + if (resetRef.current !== null) { + window.clearTimeout(resetRef.current) + } + + setStatus('copied') + resetRef.current = window.setTimeout(() => { + setStatus('idle') + resetRef.current = null + }, COPIED_RESET_MS) + onCopied?.() + } catch (error) { + onCopyError?.(error) + + if (resetRef.current !== null) { + window.clearTimeout(resetRef.current) + } + + setStatus('error') + resetRef.current = window.setTimeout(() => { + setStatus('idle') + resetRef.current = null + }, COPIED_RESET_MS) + } + }, + [haptic, onCopied, onCopyError, preventDefault, stopPropagation, text] + ) + + const Icon = status === 'copied' ? Check : status === 'error' ? X : Copy + const icon = <Icon className={cn('size-3.5', iconClassName)} /> + + const visibleChildren = + (showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row')) + ? status === 'copied' + ? 'Copied' + : status === 'error' + ? 'Failed' + : (children ?? label) + : null + + const content = ( + <> + {icon} + {visibleChildren} + </> + ) + + const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label) + const ariaLabel = status === 'idle' ? label : feedbackLabel + + if (appearance === 'menu-item') { + return ( + <DropdownMenuItem + className={className} + disabled={disabled} + onSelect={event => { + event.preventDefault() + void copy(event) + }} + > + {content} + </DropdownMenuItem> + ) + } + + if (appearance === 'inline') { + return ( + <button + aria-label={ariaLabel} + className={cn( + 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40', + className + )} + disabled={disabled} + onClick={event => void copy(event)} + title={feedbackLabel} + type="button" + > + {content} + </button> + ) + } + + if (appearance === 'tool-row') { + return ( + <button + aria-label={ariaLabel} + className={cn( + 'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40', + className + )} + disabled={disabled} + onClick={event => void copy(event)} + title={feedbackLabel} + type="button" + > + {icon} + </button> + ) + } + + return ( + <Button + aria-label={ariaLabel} + className={className} + disabled={disabled} + onClick={event => void copy(event)} + size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')} + title={feedbackLabel} + type="button" + variant={buttonVariant} + > + {content} + </Button> + ) +} diff --git a/apps/desktop/src/components/ui/dialog.tsx b/apps/desktop/src/components/ui/dialog.tsx new file mode 100644 index 000000000..4f732954a --- /dev/null +++ b/apps/desktop/src/components/ui/dialog.tsx @@ -0,0 +1,124 @@ +import { Dialog as DialogPrimitive } from 'radix-ui' +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { + return <DialogPrimitive.Root data-slot="dialog" {...props} /> +} + +function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> +} + +function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> +} + +function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> +} + +function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { + return ( + <DialogPrimitive.Overlay + className={cn( + 'fixed inset-0 z-[120] pointer-events-auto bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', + className + )} + data-slot="dialog-overlay" + {...props} + /> + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Content> & { + showCloseButton?: boolean +}) { + return ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + className={cn( + 'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + data-slot="dialog-content" + {...props} + > + {children} + {showCloseButton && ( + <DialogPrimitive.Close + className="absolute right-2.5 top-2.5 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none" + data-slot="dialog-close-button" + > + <Codicon name="close" size="1rem" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + )} + </DialogPrimitive.Content> + </DialogPortal> + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('flex flex-col gap-1 text-center sm:text-left', className)} + data-slot="dialog-header" + {...props} + /> + ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} + data-slot="dialog-footer" + {...props} + /> + ) +} + +function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { + return ( + <DialogPrimitive.Title + className={cn('text-[0.9375rem] font-semibold tracking-tight text-foreground', className)} + data-slot="dialog-title" + {...props} + /> + ) +} + +function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) { + return ( + <DialogPrimitive.Description + className={cn( + 'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', + className + )} + data-slot="dialog-description" + {...props} + /> + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger +} diff --git a/apps/desktop/src/components/ui/disclosure-caret.tsx b/apps/desktop/src/components/ui/disclosure-caret.tsx new file mode 100644 index 000000000..850ba4691 --- /dev/null +++ b/apps/desktop/src/components/ui/disclosure-caret.tsx @@ -0,0 +1,20 @@ +import { Codicon, type CodiconProps } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +interface DisclosureCaretProps extends Omit<CodiconProps, 'name'> { + open: boolean +} + +// Chrome caret for collapsible sections: points right when closed (▶), +// rotates to point down (▼) when open. Override `className` to layer +// hover/opacity styling; twMerge resolves transition conflicts. +export function DisclosureCaret({ className, open, size = '0.75rem', ...props }: DisclosureCaretProps) { + return ( + <Codicon + className={cn('transition-transform duration-150', open && 'rotate-90', className)} + name="chevron-right" + size={size} + {...props} + /> + ) +} diff --git a/apps/desktop/src/components/ui/dropdown-menu.tsx b/apps/desktop/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..ee180726b --- /dev/null +++ b/apps/desktop/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,217 @@ +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui' +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { + return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { + return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { + return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { + return ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + className={cn( + 'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + data-slot="dropdown-menu-content" + sideOffset={sideOffset} + {...props} + /> + </DropdownMenuPrimitive.Portal> + ) +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { + return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + <DropdownMenuPrimitive.Item + className={cn( + "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!", + className + )} + data-inset={inset} + data-slot="dropdown-menu-item" + data-variant={variant} + {...props} + /> + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { + return ( + <DropdownMenuPrimitive.CheckboxItem + checked={checked} + className={cn( + "relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5", + className + )} + data-slot="dropdown-menu-checkbox-item" + {...props} + > + <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Codicon name="check" size="1rem" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> + ) +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { + return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { + return ( + <DropdownMenuPrimitive.RadioItem + className={cn( + "relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5", + className + )} + data-slot="dropdown-menu-radio-item" + {...props} + > + <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Codicon name="primitive-dot" size="0.5rem" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean +}) { + return ( + <DropdownMenuPrimitive.Label + className={cn('px-2 py-1 text-xs font-medium text-(--ui-text-tertiary) data-[inset]:pl-7', className)} + data-inset={inset} + data-slot="dropdown-menu-label" + {...props} + /> + ) +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { + return ( + <DropdownMenuPrimitive.Separator + className={cn('-mx-1 my-1 h-px bg-(--ui-stroke-tertiary)', className)} + data-slot="dropdown-menu-separator" + {...props} + /> + ) +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + <span + className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} + data-slot="dropdown-menu-shortcut" + {...props} + /> + ) +} + +function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { + return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean +}) { + return ( + <DropdownMenuPrimitive.SubTrigger + className={cn( + "flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)", + className + )} + data-inset={inset} + data-slot="dropdown-menu-sub-trigger" + {...props} + > + {children} + <Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" /> + </DropdownMenuPrimitive.SubTrigger> + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { + return ( + <DropdownMenuPrimitive.SubContent + className={cn( + 'z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + data-slot="dropdown-menu-sub-content" + {...props} + /> + ) +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} diff --git a/apps/desktop/src/components/ui/fade-text.tsx b/apps/desktop/src/components/ui/fade-text.tsx new file mode 100644 index 000000000..f80c32c21 --- /dev/null +++ b/apps/desktop/src/components/ui/fade-text.tsx @@ -0,0 +1,110 @@ +import type { ComponentProps, CSSProperties } from 'react' +import { memo, useCallback, useRef, useState } from 'react' + +import { useResizeObserver } from '@/hooks/use-resize-observer' +import { cn } from '@/lib/utils' + +interface FadeTextProps extends Omit<ComponentProps<'span'>, 'children'> { + children: React.ReactNode + /** + * Width of the fade region on the trailing edge. Accepts any CSS length. + * Defaults to 3rem so long strings clearly trail off — short enough to + * preserve readable content, long enough to feel like a deliberate fade + * rather than a clipped ellipsis. + */ + fadeWidth?: string +} + +/** + * Single-line text that fades out instead of truncating with an ellipsis. + * + * Uses an inline mask-image so the fade resolves against whatever the parent + * background is — no need to know the surface color, no after-pseudo overlap. + * The mask is only applied when the text is actually overflowing, so short + * strings render as plain text without an unnecessary gradient on their tail. + * + * Layout reads (`el.scrollWidth`) are forced reflows. To avoid measuring + * once per parent re-render — which during streaming happens on every token — + * we only re-measure when the ResizeObserver fires (real size changes), not + * on every `children` reference change. Wrapped in `memo` with a custom + * comparator so scalar-string children skip re-render entirely when the text + * is unchanged but the parent re-rendered. + */ +function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) { + const ref = useRef<HTMLSpanElement>(null) + const [overflowing, setOverflowing] = useState(false) + + const measureOverflow = useCallback(() => { + const el = ref.current + + if (!el) { + return + } + + setOverflowing(el.scrollWidth - el.clientWidth > 1) + }, []) + + useResizeObserver(measureOverflow, ref) + + const maskStyle: CSSProperties = overflowing + ? { + maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`, + WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`, + ...style + } + : (style ?? {}) + + return ( + <span + {...rest} + className={cn('block min-w-0 max-w-full overflow-hidden whitespace-nowrap', className)} + ref={ref} + style={maskStyle} + > + {children} + </span> + ) +} + +function styleEqual(a: CSSProperties | undefined, b: CSSProperties | undefined) { + if (a === b) { + return true + } + + if (!a || !b) { + return false + } + + const aKeys = Object.keys(a) + + if (aKeys.length !== Object.keys(b).length) { + return false + } + + for (const k of aKeys) { + if ((a as Record<string, unknown>)[k] !== (b as Record<string, unknown>)[k]) { + return false + } + } + + return true +} + +export const FadeText = memo(FadeTextImpl, (prev, next) => { + if (prev.className !== next.className) { + return false + } + + if (prev.fadeWidth !== next.fadeWidth) { + return false + } + + if (!styleEqual(prev.style, next.style)) { + return false + } + + // Cheap path: the common case is a scalar string/number child. Identity + // comparison is correct for any other element type (a new JSX node should + // force a re-render). + return prev.children === next.children +}) diff --git a/apps/desktop/src/components/ui/input.tsx b/apps/desktop/src/components/ui/input.tsx new file mode 100644 index 000000000..ddb8de6b2 --- /dev/null +++ b/apps/desktop/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + <input + className={cn( + 'desktop-input-chrome h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + className + )} + data-slot="input" + type={type} + {...props} + /> + ) +} + +export { Input } diff --git a/apps/desktop/src/components/ui/kbd.tsx b/apps/desktop/src/components/ui/kbd.tsx new file mode 100644 index 000000000..7f5ecf28d --- /dev/null +++ b/apps/desktop/src/components/ui/kbd.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + <kbd + className={cn( + 'inline-grid h-4 min-w-4 place-items-center rounded-sm border border-border/70 bg-muted/45 px-1 font-mono text-[0.5625rem] font-medium leading-none text-muted-foreground shadow-xs', + className + )} + data-slot="kbd" + {...props} + /> + ) +} + +interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'> { + keys: string[] +} + +function KbdGroup({ className, keys, ...props }: KbdGroupProps) { + return ( + <span + aria-label={keys.join(' ')} + className={cn('inline-flex shrink-0 items-center gap-0.5 opacity-55', className)} + data-slot="kbd-group" + {...props} + > + {keys.map(key => ( + <Kbd key={key}>{key}</Kbd> + ))} + </span> + ) +} + +export { Kbd, KbdGroup } diff --git a/apps/desktop/src/components/ui/loader.tsx b/apps/desktop/src/components/ui/loader.tsx new file mode 100644 index 000000000..2bc9eaadb --- /dev/null +++ b/apps/desktop/src/components/ui/loader.tsx @@ -0,0 +1,558 @@ +import { type ComponentProps, useEffect, useRef } from 'react' + +import { cn } from '@/lib/utils' + +export const LOADER_TYPES = [ + 'original-thinking', + 'thinking-five', + 'thinking-nine', + 'rose-orbit', + 'rose-curve', + 'rose-two', + 'rose-three', + 'rose-four', + 'lissajous-drift', + 'lemniscate-bloom', + 'hypotrochoid-loop', + 'three-petal-spiral', + 'four-petal-spiral', + 'five-petal-spiral', + 'six-petal-spiral', + 'butterfly-phase', + 'cardioid-glow', + 'cardioid-heart', + 'heart-wave', + 'spiral-search', + 'fourier-flow' +] as const + +export type LoaderType = (typeof LOADER_TYPES)[number] + +interface Point { + x: number + y: number +} + +interface LoaderCurve { + durationMs: number + name: string + particleCount: number + point: (progress: number, detailScale: number) => Point + pulseDurationMs: number + rotate: boolean + rotationDurationMs: number + strokeWidth: number + trailSpan: number +} + +interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> { + label?: string + pathSteps?: number + strokeScale?: number + type?: LoaderType +} + +interface BaseCurveOptions extends Pick< + LoaderCurve, + 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan' +> { + point?: LoaderCurve['point'] + rotate?: boolean + rotationDurationMs?: number +} + +const TWO_PI = Math.PI * 2 + +const LOADER_CURVES: Record<LoaderType, LoaderCurve> = { + 'original-thinking': thinkingCurve('Original Thinking', 7, { + durationMs: 4600, + particleCount: 64, + pulseDurationMs: 4200, + rotationDurationMs: 28000, + trailSpan: 0.38 + }), + 'thinking-five': thinkingCurve('Thinking Five', 5, { + durationMs: 4600, + particleCount: 62, + pulseDurationMs: 4200, + rotationDurationMs: 28000, + trailSpan: 0.38 + }), + 'thinking-nine': thinkingCurve('Thinking Nine', 9, { + durationMs: 4700, + particleCount: 68, + pulseDurationMs: 4200, + rotationDurationMs: 30000, + trailSpan: 0.39 + }), + 'rose-orbit': { + ...baseCurve('Rose Orbit', { + durationMs: 5200, + particleCount: 72, + pulseDurationMs: 4600, + rotate: true, + rotationDurationMs: 28000, + strokeWidth: 5.2, + trailSpan: 0.42 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const r = 7 - 2.7 * detailScale * Math.cos(7 * t) + + return { + x: 50 + Math.cos(t) * r * 3.9, + y: 50 + Math.sin(t) * r * 3.9 + } + } + }, + 'rose-curve': roseCurve('Rose Curve', 5, { + durationMs: 5400, + particleCount: 78, + pulseDurationMs: 4600, + strokeWidth: 4.5, + trailSpan: 0.32 + }), + 'rose-two': roseCurve('Rose Two', 2, { + durationMs: 5200, + particleCount: 74, + pulseDurationMs: 4300, + strokeWidth: 4.6, + trailSpan: 0.3 + }), + 'rose-three': roseCurve('Rose Three', 3, { + durationMs: 5300, + particleCount: 76, + pulseDurationMs: 4400, + strokeWidth: 4.6, + trailSpan: 0.31 + }), + 'rose-four': roseCurve('Rose Four', 4, { + durationMs: 5400, + particleCount: 78, + pulseDurationMs: 4500, + strokeWidth: 4.6, + trailSpan: 0.32 + }), + 'lissajous-drift': { + ...baseCurve('Lissajous Drift', { + durationMs: 6000, + particleCount: 68, + pulseDurationMs: 5400, + strokeWidth: 4.7, + trailSpan: 0.34 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const amp = 24 + detailScale * 6 + + return { + x: 50 + Math.sin(3 * t + 1.57) * amp, + y: 50 + Math.sin(4 * t) * (amp * 0.92) + } + } + }, + 'lemniscate-bloom': { + ...baseCurve('Lemniscate Bloom', { + durationMs: 5600, + particleCount: 70, + pulseDurationMs: 5000, + rotationDurationMs: 34000, + strokeWidth: 4.8, + trailSpan: 0.4 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const scale = 20 + detailScale * 7 + const denom = 1 + Math.sin(t) ** 2 + + return { + x: 50 + (scale * Math.cos(t)) / denom, + y: 50 + (scale * Math.sin(t) * Math.cos(t)) / denom + } + } + }, + 'hypotrochoid-loop': { + ...baseCurve('Hypotrochoid Loop', { + durationMs: 7600, + particleCount: 82, + pulseDurationMs: 6200, + rotationDurationMs: 42000, + strokeWidth: 4.6, + trailSpan: 0.46 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const r = 2.7 + detailScale * 0.45 + const d = 4.8 + detailScale * 1.2 + const x = (8.2 - r) * Math.cos(t) + d * Math.cos(((8.2 - r) / r) * t) + const y = (8.2 - r) * Math.sin(t) - d * Math.sin(((8.2 - r) / r) * t) + + return { + x: 50 + x * 3.05, + y: 50 + y * 3.05 + } + } + }, + 'three-petal-spiral': spiralPetalCurve('Three-Petal Spiral', 3, 82), + 'four-petal-spiral': spiralPetalCurve('Four-Petal Spiral', 4, 84), + 'five-petal-spiral': spiralPetalCurve('Five-Petal Spiral', 5, 85), + 'six-petal-spiral': spiralPetalCurve('Six-Petal Spiral', 6, 86), + 'butterfly-phase': { + ...baseCurve('Butterfly Phase', { + durationMs: 9000, + particleCount: 88, + pulseDurationMs: 7000, + rotationDurationMs: 50000, + strokeWidth: 4.4, + trailSpan: 0.32 + }), + point(progress, detailScale) { + const t = progress * Math.PI * 12 + + const butterfly = Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.sin(t / 12) ** 5 + + const scale = 4.6 + detailScale * 0.45 + + return { + x: 50 + Math.sin(t) * butterfly * scale, + y: 50 + Math.cos(t) * butterfly * scale + } + } + }, + 'cardioid-glow': cardioidCurve('Cardioid Glow', { + a: 8.4, + particleCount: 72, + pointFor(t, r, scale) { + return { + x: 50 + Math.cos(t) * r * scale, + y: 50 + Math.sin(t) * r * scale + } + }, + rFor(t, a) { + return a * (1 - Math.cos(t)) + } + }), + 'cardioid-heart': cardioidCurve('Cardioid Heart', { + a: 8.8, + particleCount: 74, + pointFor(t, r, scale) { + const baseX = Math.cos(t) * r + const baseY = Math.sin(t) * r + + return { + x: 50 - baseY * scale, + y: 50 - baseX * scale + } + }, + rFor(t, a) { + return a * (1 + Math.cos(t)) + } + }), + 'heart-wave': { + ...baseCurve('Heart Wave', { + durationMs: 8400, + particleCount: 104, + pulseDurationMs: 5600, + rotationDurationMs: 22000, + strokeWidth: 3.9, + trailSpan: 0.18 + }), + point(progress, detailScale) { + const root = 3.3 + const xLimit = Math.sqrt(root) + const x = -xLimit + progress * xLimit * 2 + const safeRoot = Math.max(0, root - x * x) + const wave = 0.9 * Math.sqrt(safeRoot) * Math.sin(6.4 * Math.PI * x) + const curve = Math.abs(x) ** (2 / 3) + const y = curve + wave + + return { + x: 50 + x * 23.2, + y: 18 + (1.75 - y) * (24.5 + detailScale * 1.5) + } + } + }, + 'spiral-search': { + ...baseCurve('Spiral Search', { + durationMs: 7800, + particleCount: 86, + pulseDurationMs: 6800, + rotationDurationMs: 44000, + strokeWidth: 4.3, + trailSpan: 0.28 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const angle = t * 4 + const radius = 8 + (1 - Math.cos(t)) * (8.5 + detailScale * 2.4) + + return { + x: 50 + Math.cos(angle) * radius, + y: 50 + Math.sin(angle) * radius + } + } + }, + 'fourier-flow': { + ...baseCurve('Fourier Flow', { + durationMs: 8400, + particleCount: 92, + pulseDurationMs: 6800, + rotationDurationMs: 44000, + strokeWidth: 4.2, + trailSpan: 0.31 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const mix = 1 + detailScale * 0.16 + const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4) + const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix) + + return { + x: 50 + x, + y: 50 + y + } + } + } +} + +export function Loader({ + className, + label = 'Loading', + pathSteps = 240, + role = 'status', + strokeScale = 1, + type = 'rose-curve', + ...props +}: LoaderProps) { + const config = LOADER_CURVES[type] + const groupRef = useRef<SVGGElement | null>(null) + const particleRefs = useRef<Array<SVGCircleElement | null>>([]) + const pathRef = useRef<SVGPathElement | null>(null) + + useEffect(() => { + let animationFrame = 0 + const startedAt = performance.now() + const phaseOffset = Math.random() + particleRefs.current.length = config.particleCount + + const render = (now: number) => { + const time = now - startedAt + const progress = ((time + phaseOffset * config.durationMs) % config.durationMs) / config.durationMs + const detailScale = detailScaleFor(time, config, phaseOffset) + const rotation = rotationFor(time, config, phaseOffset) + + groupRef.current?.setAttribute('transform', `rotate(${rotation} 50 50)`) + pathRef.current?.setAttribute('d', buildPath(config, detailScale, pathSteps)) + + particleRefs.current.forEach((node, index) => { + if (!node) { + return + } + + const particle = particleFor(config, index, progress, detailScale, strokeScale) + node.setAttribute('cx', particle.x.toFixed(2)) + node.setAttribute('cy', particle.y.toFixed(2)) + node.setAttribute('r', particle.radius.toFixed(2)) + node.setAttribute('opacity', particle.opacity.toFixed(3)) + }) + + animationFrame = window.requestAnimationFrame(render) + } + + render(performance.now()) + + return () => window.cancelAnimationFrame(animationFrame) + }, [config, pathSteps, strokeScale]) + + return ( + <div + {...props} + aria-label={props['aria-label'] ?? label} + className={cn('inline-grid size-10 place-items-center text-primary', className)} + role={role} + > + <svg aria-hidden="true" className="size-full overflow-visible" fill="none" viewBox="0 0 100 100"> + <g ref={groupRef}> + <path + opacity="0.1" + ref={pathRef} + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={config.strokeWidth * strokeScale} + /> + {Array.from({ length: config.particleCount }, (_, index) => ( + <circle + fill="currentColor" + key={`${type}-${index}`} + ref={node => { + particleRefs.current[index] = node + }} + /> + ))} + </g> + </svg> + </div> + ) +} + +function baseCurve(name: string, options: BaseCurveOptions): LoaderCurve { + return { + durationMs: options.durationMs, + name, + particleCount: options.particleCount, + point: options.point ?? (() => ({ x: 50, y: 50 })), + pulseDurationMs: options.pulseDurationMs, + rotate: options.rotate ?? false, + rotationDurationMs: options.rotationDurationMs ?? 36000, + strokeWidth: options.strokeWidth, + trailSpan: options.trailSpan + } +} + +function thinkingCurve( + name: string, + petalCount: number, + options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'rotationDurationMs' | 'trailSpan'> +): LoaderCurve { + return { + ...baseCurve(name, { + ...options, + rotate: true, + strokeWidth: 5.5 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const x = 7 * Math.cos(t) - 3 * detailScale * Math.cos(petalCount * t) + const y = 7 * Math.sin(t) - 3 * detailScale * Math.sin(petalCount * t) + + return { + x: 50 + x * 3.9, + y: 50 + y * 3.9 + } + } + } +} + +function roseCurve( + name: string, + k: number, + options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'> +): LoaderCurve { + return { + ...baseCurve(name, { + ...options, + rotate: true, + rotationDurationMs: 28000 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const a = 9.2 + detailScale * 0.6 + const r = a * (0.72 + detailScale * 0.28) * Math.cos(k * t) + + return { + x: 50 + Math.cos(t) * r * 3.25, + y: 50 + Math.sin(t) * r * 3.25 + } + } + } +} + +function spiralPetalCurve(name: string, spiralR: number, particleCount: number): LoaderCurve { + return { + ...baseCurve(name, { + durationMs: 4600, + particleCount, + pulseDurationMs: 4200, + rotate: true, + rotationDurationMs: 28000, + strokeWidth: 4.4, + trailSpan: 0.34 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const spiralr = 1 + const d = 3 + detailScale * 0.25 + const baseX = (spiralR - spiralr) * Math.cos(t) + d * Math.cos(((spiralR - spiralr) / spiralr) * t) + const baseY = (spiralR - spiralr) * Math.sin(t) - d * Math.sin(((spiralR - spiralr) / spiralr) * t) + const scale = 2.2 + detailScale * 0.45 + + return { + x: 50 + baseX * scale, + y: 50 + baseY * scale + } + } + } +} + +function cardioidCurve( + name: string, + options: { + a: number + particleCount: number + pointFor: (t: number, r: number, scale: number) => Point + rFor: (t: number, a: number) => number + } +): LoaderCurve { + return { + ...baseCurve(name, { + durationMs: 6200, + particleCount: options.particleCount, + pulseDurationMs: 5200, + rotationDurationMs: 36000, + strokeWidth: 4.9, + trailSpan: 0.36 + }), + point(progress, detailScale) { + const t = progress * TWO_PI + const a = options.a + detailScale * 0.8 + const r = options.rFor(t, a) + + return options.pointFor(t, r, 2.15) + } + } +} + +function buildPath(config: LoaderCurve, detailScale: number, steps: number) { + return Array.from({ length: steps + 1 }, (_, index) => { + const point = config.point(index / steps, detailScale) + + return `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}` + }).join(' ') +} + +function detailScaleFor(time: number, config: LoaderCurve, phaseOffset: number) { + const pulseProgress = + ((time + phaseOffset * config.pulseDurationMs) % config.pulseDurationMs) / config.pulseDurationMs + + const pulseAngle = pulseProgress * TWO_PI + + return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48 +} + +function normalizeProgress(progress: number) { + return ((progress % 1) + 1) % 1 +} + +function particleFor(config: LoaderCurve, index: number, progress: number, detailScale: number, strokeScale: number) { + const tailOffset = index / (config.particleCount - 1) + const point = config.point(normalizeProgress(progress - tailOffset * config.trailSpan), detailScale) + const fade = (1 - tailOffset) ** 0.56 + + return { + opacity: 0.04 + fade * 0.96, + radius: (0.9 + fade * 2.7) * strokeScale, + x: point.x, + y: point.y + } +} + +function rotationFor(time: number, config: LoaderCurve, phaseOffset: number) { + if (!config.rotate) { + return 0 + } + + return ( + -(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360 + ) +} diff --git a/apps/desktop/src/components/ui/pagination.tsx b/apps/desktop/src/components/ui/pagination.tsx new file mode 100644 index 000000000..f8be00078 --- /dev/null +++ b/apps/desktop/src/components/ui/pagination.tsx @@ -0,0 +1,107 @@ +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { + return ( + <nav + aria-label="pagination" + className={cn('mx-auto flex w-full justify-center', className)} + data-slot="pagination" + {...props} + /> + ) +} + +function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) { + return ( + <ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} /> + ) +} + +function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) { + return <li className={cn('flex h-5 items-center', className)} data-slot="pagination-item" {...props} /> +} + +interface PaginationButtonProps extends React.ComponentProps<'button'> { + isActive?: boolean +} + +function PaginationButton({ className, isActive, ...props }: PaginationButtonProps) { + return ( + <button + aria-current={isActive ? 'page' : undefined} + className={cn( + 'inline-flex h-5 min-w-5 items-center justify-center rounded border border-transparent px-1 text-[0.6875rem] leading-none tabular-nums transition-colors disabled:pointer-events-none disabled:opacity-45', + isActive + ? 'border-border bg-background text-foreground shadow-xs' + : 'text-muted-foreground hover:bg-accent hover:text-foreground', + className + )} + data-active={isActive} + data-slot="pagination-button" + type="button" + {...props} + /> + ) +} + +function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) { + return ( + <button + aria-label="Go to previous page" + className={cn( + 'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45', + className + )} + data-slot="pagination-previous" + type="button" + {...props} + > + <Codicon name="chevron-left" size="0.75rem" /> + <span>Prev</span> + </button> + ) +} + +function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) { + return ( + <button + aria-label="Go to next page" + className={cn( + 'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45', + className + )} + data-slot="pagination-next" + type="button" + {...props} + > + <span>Next</span> + <Codicon name="chevron-right" size="0.75rem" /> + </button> + ) +} + +function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) { + return ( + <span + aria-hidden + className={cn('flex size-5 items-center justify-center', className)} + data-slot="pagination-ellipsis" + {...props} + > + <Codicon name="ellipsis" size="0.75rem" /> + </span> + ) +} + +export { + Pagination, + PaginationButton, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationNext, + PaginationPrevious +} diff --git a/apps/desktop/src/components/ui/scroll-area.tsx b/apps/desktop/src/components/ui/scroll-area.tsx new file mode 100644 index 000000000..58b9ff0ea --- /dev/null +++ b/apps/desktop/src/components/ui/scroll-area.tsx @@ -0,0 +1,43 @@ +import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) { + return ( + <ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} data-slot="scroll-area" {...props}> + <ScrollAreaPrimitive.Viewport className="size-full outline-none" data-slot="scroll-area-viewport"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> + ) +} + +function ScrollBar({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) { + return ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + className={cn( + 'flex touch-none select-none p-px transition-colors', + orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent', + orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent', + className + )} + data-slot="scroll-area-scrollbar" + orientation={orientation} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb + className="relative flex-1 rounded-full bg-muted-foreground/30 hover:bg-muted-foreground/45" + data-slot="scroll-area-thumb" + /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> + ) +} + +export { ScrollArea, ScrollBar } diff --git a/apps/desktop/src/components/ui/select.tsx b/apps/desktop/src/components/ui/select.tsx new file mode 100644 index 000000000..c207fc18b --- /dev/null +++ b/apps/desktop/src/components/ui/select.tsx @@ -0,0 +1,85 @@ +import { Select as SelectPrimitive } from 'radix-ui' +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { + return <SelectPrimitive.Root data-slot="select" {...props} /> +} + +function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) { + return ( + <SelectPrimitive.Trigger + className={cn( + 'flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0', + className + )} + data-slot="select-trigger" + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <Codicon className="opacity-60" name="chevron-down" size="1rem" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> + ) +} + +function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { + return <SelectPrimitive.Value data-slot="select-value" {...props} /> +} + +function SelectContent({ + className, + children, + position = 'popper', + ...props +}: React.ComponentProps<typeof SelectPrimitive.Content>) { + return ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + className={cn( + 'relative z-[140] max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2', + position === 'popper' && + 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', + className + )} + data-slot="select-content" + position={position} + {...props} + > + <SelectPrimitive.Viewport + className={cn( + 'p-1', + position === 'popper' && 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)' + )} + > + {children} + </SelectPrimitive.Viewport> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> + ) +} + +function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { + return ( + <SelectPrimitive.Item + className={cn( + 'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50', + className + )} + data-slot="select-item" + {...props} + > + <span className="absolute right-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Codicon name="check" size="1rem" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> + ) +} + +export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } diff --git a/apps/desktop/src/components/ui/separator.tsx b/apps/desktop/src/components/ui/separator.tsx new file mode 100644 index 000000000..ea5dc859a --- /dev/null +++ b/apps/desktop/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import { Separator as SeparatorPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { + return ( + <SeparatorPrimitive.Root + className={cn( + 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', + className + )} + data-slot="separator" + decorative={decorative} + orientation={orientation} + {...props} + /> + ) +} + +export { Separator } diff --git a/apps/desktop/src/components/ui/sheet.tsx b/apps/desktop/src/components/ui/sheet.tsx new file mode 100644 index 000000000..0cc619a4d --- /dev/null +++ b/apps/desktop/src/components/ui/sheet.tsx @@ -0,0 +1,110 @@ +'use client' + +import { Dialog as SheetPrimitive } from 'radix-ui' +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { + return <SheetPrimitive.Root data-slot="sheet" {...props} /> +} + +function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) { + return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> +} + +function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) { + return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> +} + +function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) { + return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> +} + +function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) { + return ( + <SheetPrimitive.Overlay + className={cn( + 'fixed inset-0 z-50 bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', + className + )} + data-slot="sheet-overlay" + {...props} + /> + ) +} + +function SheetContent({ + className, + children, + side = 'right', + showCloseButton = true, + ...props +}: React.ComponentProps<typeof SheetPrimitive.Content> & { + side?: 'top' | 'right' | 'bottom' | 'left' + showCloseButton?: boolean +}) { + return ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + className={cn( + 'fixed z-50 flex flex-col gap-3 border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) text-[length:var(--conversation-text-font-size)] shadow-md transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500', + side === 'right' && + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + side === 'left' && + 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + side === 'top' && + 'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + side === 'bottom' && + 'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + className + )} + data-slot="sheet-content" + {...props} + > + {children} + {showCloseButton && ( + <SheetPrimitive.Close className="absolute top-3 right-3 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 ring-offset-background transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary"> + <Codicon name="close" size="1rem" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + )} + </SheetPrimitive.Content> + </SheetPortal> + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return <div className={cn('flex flex-col gap-1 p-3', className)} data-slot="sheet-header" {...props} /> +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return <div className={cn('mt-auto flex flex-col gap-2 p-3', className)} data-slot="sheet-footer" {...props} /> +} + +function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) { + return ( + <SheetPrimitive.Title + className={cn('text-[0.9375rem] font-semibold text-foreground', className)} + data-slot="sheet-title" + {...props} + /> + ) +} + +function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) { + return ( + <SheetPrimitive.Description + className={cn( + 'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', + className + )} + data-slot="sheet-description" + {...props} + /> + ) +} + +export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } diff --git a/apps/desktop/src/components/ui/sidebar.tsx b/apps/desktop/src/components/ui/sidebar.tsx new file mode 100644 index 000000000..eba6fb8e4 --- /dev/null +++ b/apps/desktop/src/components/ui/sidebar.tsx @@ -0,0 +1,681 @@ +'use client' + +import { cva, type VariantProps } from 'class-variance-authority' +import { Slot } from 'radix-ui' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { Skeleton } from '@/components/ui/skeleton' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { useIsMobile } from '@/hooks/use-mobile' +import { PanelLeftIcon } from '@/lib/icons' +import { cn } from '@/lib/utils' + +const SIDEBAR_COOKIE_NAME = 'sidebar_state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' + +type SidebarContextProps = { + state: 'expanded' | 'collapsed' + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext<SidebarContextProps | null>(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.') + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed' + + const contextValue = React.useMemo<SidebarContextProps>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + className={cn('group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', className)} + data-slot="sidebar-wrapper" + style={ + { + '--sidebar-width': SIDEBAR_WIDTH, + '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, + ...style + } as React.CSSProperties + } + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ) +} + +function Sidebar({ + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props +}: React.ComponentProps<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === 'none') { + return ( + <div + className={cn('flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', className)} + data-slot="sidebar" + {...props} + > + {children} + </div> + ) + } + + if (isMobile) { + return ( + <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> + <SheetContent + className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + data-mobile="true" + data-sidebar="sidebar" + data-slot="sidebar" + side={side} + style={ + { + '--sidebar-width': SIDEBAR_WIDTH_MOBILE + } as React.CSSProperties + } + > + <SheetHeader className="sr-only"> + <SheetTitle>Sidebar</SheetTitle> + <SheetDescription>Displays the mobile sidebar.</SheetDescription> + </SheetHeader> + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ) + } + + return ( + <div + className="group peer hidden text-sidebar-foreground md:block" + data-collapsible={state === 'collapsed' ? collapsible : ''} + data-side={side} + data-slot="sidebar" + data-state={state} + data-variant={variant} + > + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', + 'group-data-[collapsible=offcanvas]:w-0', + 'group-data-[side=right]:rotate-180', + variant === 'floating' || variant === 'inset' + ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' + : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)' + )} + data-slot="sidebar-gap" + /> + <div + className={cn( + 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', + side === 'left' + ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' + : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', + // Adjust the padding for floating and inset variants. + variant === 'floating' || variant === 'inset' + ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+0.125rem)]' + : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', + className + )} + data-slot="sidebar-container" + {...props} + > + <div + className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm" + data-sidebar="sidebar" + data-slot="sidebar-inner" + > + {children} + </div> + </div> + </div> + ) +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { + const { toggleSidebar } = useSidebar() + + return ( + <Button + className={cn('size-7', className)} + data-sidebar="trigger" + data-slot="sidebar-trigger" + onClick={event => { + onClick?.(event) + toggleSidebar() + }} + size="icon" + variant="ghost" + {...props} + > + <PanelLeftIcon /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar() + + return ( + <button + aria-label="Toggle Sidebar" + className={cn( + 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex', + 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', + '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', + 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar', + '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', + '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', + className + )} + data-sidebar="rail" + data-slot="sidebar-rail" + onClick={toggleSidebar} + tabIndex={-1} + title="Toggle Sidebar" + {...props} + /> + ) +} + +function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) { + return ( + <main + className={cn( + 'relative flex w-full flex-1 flex-col bg-background', + 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', + className + )} + data-slot="sidebar-inset" + {...props} + /> + ) +} + +function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) { + return ( + <Input + className={cn('h-8 w-full bg-background shadow-none', className)} + data-sidebar="input" + data-slot="sidebar-input" + {...props} + /> + ) +} + +function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('flex flex-col gap-2 p-2', className)} + data-sidebar="header" + data-slot="sidebar-header" + {...props} + /> + ) +} + +function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('flex flex-col gap-2 p-2', className)} + data-sidebar="footer" + data-slot="sidebar-footer" + {...props} + /> + ) +} + +function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { + return ( + <Separator + className={cn('mx-2 w-auto bg-sidebar-border', className)} + data-sidebar="separator" + data-slot="sidebar-separator" + {...props} + /> + ) +} + +function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn( + 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', + className + )} + data-sidebar="content" + data-slot="sidebar-content" + {...props} + /> + ) +} + +function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('relative flex w-full min-w-0 flex-col p-2', className)} + data-sidebar="group" + data-slot="sidebar-group" + {...props} + /> + ) +} + +function SidebarGroupLabel({ + className, + asChild = false, + ...props +}: React.ComponentProps<'div'> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'div' + + return ( + <Comp + className={cn( + 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', + className + )} + data-sidebar="group-label" + data-slot="sidebar-group-label" + {...props} + /> + ) +} + +function SidebarGroupAction({ + className, + asChild = false, + ...props +}: React.ComponentProps<'button'> & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'button' + + return ( + <Comp + className={cn( + 'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + // Increases the hit area of the button on mobile. + 'after:absolute after:-inset-2 md:after:hidden', + 'group-data-[collapsible=icon]:hidden', + className + )} + data-sidebar="group-action" + data-slot="sidebar-group-action" + {...props} + /> + ) +} + +function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn('w-full text-sm', className)} + data-sidebar="group-content" + data-slot="sidebar-group-content" + {...props} + /> + ) +} + +function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) { + return ( + <ul + className={cn('flex w-full min-w-0 flex-col gap-1', className)} + data-sidebar="menu" + data-slot="sidebar-menu" + {...props} + /> + ) +} + +function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) { + return ( + <li + className={cn('group/menu-item relative', className)} + data-sidebar="menu-item" + data-slot="sidebar-menu-item" + {...props} + /> + ) +} + +const sidebarMenuButtonVariants = cva( + 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + { + variants: { + variant: { + default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', + outline: + 'bg-background shadow-[0_0_0_0.0625rem_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_0.0625rem_hsl(var(--sidebar-accent))]' + }, + size: { + default: 'h-8 text-sm', + sm: 'h-7 text-xs', + lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +) + +function SidebarMenuButton({ + asChild = false, + isActive = false, + variant = 'default', + size = 'default', + tooltip, + className, + ...props +}: React.ComponentProps<'button'> & { + asChild?: boolean + isActive?: boolean + tooltip?: string | React.ComponentProps<typeof TooltipContent> +} & VariantProps<typeof sidebarMenuButtonVariants>) { + const Comp = asChild ? Slot.Root : 'button' + const { isMobile, state } = useSidebar() + + const button = ( + <Comp + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + data-active={isActive} + data-sidebar="menu-button" + data-size={size} + data-slot="sidebar-menu-button" + {...props} + /> + ) + + if (!tooltip) { + return button + } + + if (typeof tooltip === 'string') { + tooltip = { + children: tooltip + } + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent align="center" hidden={state !== 'collapsed' || isMobile} side="right" {...tooltip} /> + </Tooltip> + ) +} + +function SidebarMenuAction({ + className, + asChild = false, + showOnHover = false, + ...props +}: React.ComponentProps<'button'> & { + asChild?: boolean + showOnHover?: boolean +}) { + const Comp = asChild ? Slot.Root : 'button' + + return ( + <Comp + className={cn( + 'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + // Increases the hit area of the button on mobile. + 'after:absolute after:-inset-2 md:after:hidden', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + showOnHover && + 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0', + className + )} + data-sidebar="menu-action" + data-slot="sidebar-menu-action" + {...props} + /> + ) +} + +function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + className={cn( + 'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none', + 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + className + )} + data-sidebar="menu-badge" + data-slot="sidebar-menu-badge" + {...props} + /> + ) +} + +function SidebarMenuSkeleton({ + className, + showIcon = false, + ...props +}: React.ComponentProps<'div'> & { + showIcon?: boolean +}) { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%` + }, []) + + return ( + <div + className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} + data-sidebar="menu-skeleton" + data-slot="sidebar-menu-skeleton" + {...props} + > + {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />} + <Skeleton + className="h-4 max-w-(--skeleton-width) flex-1" + data-sidebar="menu-skeleton-text" + style={ + { + '--skeleton-width': width + } as React.CSSProperties + } + /> + </div> + ) +} + +function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) { + return ( + <ul + className={cn( + 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5', + 'group-data-[collapsible=icon]:hidden', + className + )} + data-sidebar="menu-sub" + data-slot="sidebar-menu-sub" + {...props} + /> + ) +} + +function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) { + return ( + <li + className={cn('group/menu-sub-item relative', className)} + data-sidebar="menu-sub-item" + data-slot="sidebar-menu-sub-item" + {...props} + /> + ) +} + +function SidebarMenuSubButton({ + asChild = false, + size = 'md', + isActive = false, + className, + ...props +}: React.ComponentProps<'a'> & { + asChild?: boolean + size?: 'sm' | 'md' + isActive?: boolean +}) { + const Comp = asChild ? Slot.Root : 'a' + + return ( + <Comp + className={cn( + 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', + 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', + size === 'sm' && 'text-xs', + size === 'md' && 'text-sm', + 'group-data-[collapsible=icon]:hidden', + className + )} + data-active={isActive} + data-sidebar="menu-sub-button" + data-size={size} + data-slot="sidebar-menu-sub-button" + {...props} + /> + ) +} + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar +} diff --git a/apps/desktop/src/components/ui/skeleton.tsx b/apps/desktop/src/components/ui/skeleton.tsx new file mode 100644 index 000000000..14057fb79 --- /dev/null +++ b/apps/desktop/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils' + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return <div className={cn('animate-pulse rounded-md bg-accent', className)} data-slot="skeleton" {...props} /> +} + +export { Skeleton } diff --git a/apps/desktop/src/components/ui/switch.tsx b/apps/desktop/src/components/ui/switch.tsx new file mode 100644 index 000000000..237f696b9 --- /dev/null +++ b/apps/desktop/src/components/ui/switch.tsx @@ -0,0 +1,26 @@ +import { Switch as SwitchPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) { + return ( + <SwitchPrimitive.Root + className={cn( + 'peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-[color-mix(in_srgb,var(--dt-foreground)_18%,transparent)] bg-[color-mix(in_srgb,var(--dt-background)_58%,var(--dt-input))] shadow-[inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-foreground)_8%,transparent)] transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary', + className + )} + data-slot="switch" + {...props} + > + <SwitchPrimitive.Thumb + className={cn( + 'pointer-events-none block size-4 rounded-full bg-foreground shadow-[0_0.0625rem_0.1875rem_color-mix(in_srgb,var(--dt-background)_50%,transparent)] ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-background data-[state=unchecked]:translate-x-0' + )} + data-slot="switch-thumb" + /> + </SwitchPrimitive.Root> + ) +} + +export { Switch } diff --git a/apps/desktop/src/components/ui/tabs.tsx b/apps/desktop/src/components/ui/tabs.tsx new file mode 100644 index 000000000..ff6924b78 --- /dev/null +++ b/apps/desktop/src/components/ui/tabs.tsx @@ -0,0 +1,36 @@ +import { Tabs as TabsPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { + return <TabsPrimitive.Root className={cn('flex flex-col gap-2', className)} data-slot="tabs" {...props} /> +} + +function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) { + return ( + <TabsPrimitive.List + className={cn( + 'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground', + className + )} + data-slot="tabs-list" + {...props} + /> + ) +} + +function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { + return ( + <TabsPrimitive.Trigger + className={cn( + 'inline-flex h-7 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/35 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + className + )} + data-slot="tabs-trigger" + {...props} + /> + ) +} + +export { Tabs, TabsList, TabsTrigger } diff --git a/apps/desktop/src/components/ui/text-tab.tsx b/apps/desktop/src/components/ui/text-tab.tsx new file mode 100644 index 000000000..4e8596688 --- /dev/null +++ b/apps/desktop/src/components/ui/text-tab.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function TextTabMeta({ className, ...props }: React.ComponentProps<'span'>) { + return <span className={cn('text-[0.72em] font-normal text-(--ui-text-tertiary)', className)} {...props} /> +} + +interface TextTabProps extends React.ComponentProps<'button'> { + active?: boolean +} + +function TextTab({ active = false, children, className, type = 'button', ...props }: TextTabProps) { + return ( + <button + className={cn( + 'group/text-tab inline-flex h-7 items-center gap-1 bg-transparent px-1 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-transparent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50', + active && 'text-foreground', + className + )} + data-active={active} + type={type} + {...props} + > + {React.Children.map(children, child => + React.isValidElement(child) && child.type === TextTabMeta ? ( + child + ) : ( + <span + className={cn( + 'underline-offset-4 decoration-current/25', + active ? 'underline' : 'group-hover/text-tab:underline' + )} + > + {child} + </span> + ) + )} + </button> + ) +} + +export { TextTab, TextTabMeta } diff --git a/apps/desktop/src/components/ui/textarea.tsx b/apps/desktop/src/components/ui/textarea.tsx new file mode 100644 index 000000000..3c2fa9d43 --- /dev/null +++ b/apps/desktop/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { + return ( + <textarea + className={cn( + 'desktop-input-chrome min-h-16 w-full rounded-md border px-3 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', + className + )} + data-slot="textarea" + {...props} + /> + ) +} + +export { Textarea } diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..2f727fdbe --- /dev/null +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -0,0 +1,42 @@ +import { Tooltip as TooltipPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) { + return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} /> +} + +function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { + return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> +} + +function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { + return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Content>) { + return ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + className={cn( + 'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', + className + )} + data-slot="tooltip-content" + sideOffset={sideOffset} + {...props} + > + {children} + <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_0.125rem)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" /> + </TooltipPrimitive.Content> + </TooltipPrimitive.Portal> + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts new file mode 100644 index 000000000..d278042e8 --- /dev/null +++ b/apps/desktop/src/global.d.ts @@ -0,0 +1,323 @@ +export {} + +declare global { + interface Window { + hermesDesktop: { + getConnection: () => Promise<HermesConnection> + getBootProgress: () => Promise<DesktopBootProgress> + getConnectionConfig: () => Promise<DesktopConnectionConfig> + saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig> + applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig> + testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult> + api: <T>(request: HermesApiRequest) => Promise<T> + notify: (payload: HermesNotification) => Promise<boolean> + requestMicrophoneAccess: () => Promise<boolean> + readFileDataUrl: (filePath: string) => Promise<string> + readFileText: (filePath: string) => Promise<HermesReadFileTextResult> + selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]> + writeClipboard: (text: string) => Promise<boolean> + saveImageFromUrl: (url: string) => Promise<boolean> + saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise<string> + saveClipboardImage: () => Promise<string> + getPathForFile: (file: File) => string + normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null> + watchPreviewFile: (url: string) => Promise<HermesPreviewWatch> + stopPreviewFileWatch: (id: string) => Promise<boolean> + setTitleBarTheme?: (payload: HermesTitleBarTheme) => void + setPreviewShortcutActive?: (active: boolean) => void + openExternal: (url: string) => Promise<void> + fetchLinkTitle: (url: string) => Promise<string> + revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }> + getRecentLogs: () => Promise<{ path: string; lines: string[] }> + readDir: (path: string) => Promise<HermesReadDirResult> + gitRoot?: (path: string) => Promise<string | null> + terminal: { + dispose: (id: string) => Promise<boolean> + onData: (id: string, callback: (payload: string) => void) => () => void + onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void + resize: (id: string, size: { cols: number; rows: number }) => Promise<boolean> + start: (options?: { cols?: number; cwd?: string; rows?: number }) => Promise<HermesTerminalSession> + write: (id: string, data: string) => Promise<boolean> + } + onClosePreviewRequested?: (callback: () => void) => () => void + onOpenUpdatesRequested?: (callback: () => void) => () => void + onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void + onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void + onBackendExit: (callback: (payload: BackendExit) => void) => () => void + onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void + getBootstrapState: () => Promise<DesktopBootstrapState> + resetBootstrap: () => Promise<{ ok: boolean }> + repairBootstrap: () => Promise<{ ok: boolean }> + onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void + getVersion: () => Promise<DesktopVersionInfo> + updates: { + check: () => Promise<DesktopUpdateStatus> + apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult> + getBranch: () => Promise<{ branch: string }> + setBranch: (name: string) => Promise<{ branch: string }> + onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void + } + } + } +} + +export interface HermesTerminalSession { + cwd: string + id: string + shell: string +} + +export interface HermesTerminalExit { + code: number | null + signal: string | null +} + +export interface DesktopVersionInfo { + appVersion: string + electronVersion: string + nodeVersion: string + platform: string + hermesRoot: string +} + +export interface DesktopUpdateCommit { + sha: string + summary: string + author: string + at: number +} + +export interface DesktopUpdateStatus { + supported: boolean + branch?: string + currentBranch?: string + reason?: string + message?: string + error?: string + behind?: number + currentSha?: string + targetSha?: string + commits?: DesktopUpdateCommit[] + dirty?: boolean + fetchedAt?: number +} + +export type DesktopUpdateDirtyStrategy = 'abort' | 'stash' | 'force' + +export interface DesktopUpdateApplyOptions { + dirtyStrategy?: DesktopUpdateDirtyStrategy +} + +export interface DesktopUpdateApplyResult { + ok: boolean + branch?: string + error?: string + message?: string + /** True when no staged updater exists (CLI install) and the user should run + * `hermes update` themselves. `command` is the exact line to run. */ + manual?: boolean + command?: string + hermesRoot?: string +} + +export type DesktopUpdateStage = 'idle' | 'prepare' | 'fetch' | 'pull' | 'pydeps' | 'restart' | 'manual' | 'error' + +export interface DesktopUpdateProgress { + stage: DesktopUpdateStage + message: string + percent: number | null + error: string | null + at: number +} + +export interface HermesConnection { + baseUrl: string + isFullscreen: boolean + mode?: 'local' | 'remote' + nativeOverlayWidth: number + source?: 'env' | 'local' | 'settings' + token: string + wsUrl: string + logs: string[] + windowButtonPosition: { x: number; y: number } | null +} + +export interface HermesTitleBarTheme { + background: string + foreground: string +} + +export interface HermesWindowState { + isFullscreen: boolean + nativeOverlayWidth: number + windowButtonPosition: { x: number; y: number } | null +} + +export interface DesktopConnectionConfig { + envOverride: boolean + mode: 'local' | 'remote' + remoteTokenPreview: string | null + remoteTokenSet: boolean + remoteUrl: string +} + +export interface DesktopConnectionConfigInput { + mode: 'local' | 'remote' + remoteToken?: string + remoteUrl?: string +} + +export interface DesktopConnectionTestResult { + baseUrl: string + ok: boolean + version: string | null +} + +export interface DesktopBootProgress { + error: string | null + fakeMode: boolean + message: string + phase: string + progress: number + running: boolean + timestamp: number +} + +// First-launch install ("bootstrap") event types -- emitted by +// electron/bootstrap-runner.cjs and observed by the renderer install overlay. +// Mirrors the event shapes emitted by runBootstrap()'s onEvent callback. + +export interface DesktopBootstrapStageDescriptor { + name: string + title?: string + category?: string + needs_user_input?: boolean +} + +export type DesktopBootstrapStageState = + | 'pending' + | 'running' + | 'succeeded' + | 'skipped' + | 'failed' + +export interface DesktopBootstrapStageResult { + state: DesktopBootstrapStageState + durationMs: number | null + startedAt: number | null + json: { ok: boolean; skipped?: boolean; reason?: string | null; stage: string } | null + error: string | null +} + +export interface DesktopBootstrapUnsupportedPlatform { + platform: string + activeRoot: string + installCommand: string + docsUrl: string +} + +export interface DesktopBootstrapState { + active: boolean + manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null + stages: Record<string, DesktopBootstrapStageResult> + error: string | null + log: Array<{ ts: number; stage: string | null; line: string }> + startedAt: number | null + completedAt: number | null + unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null +} + +export type DesktopBootstrapEvent = + | { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } + | { + type: 'stage' + name: string + state: DesktopBootstrapStageState + durationMs?: number + json?: DesktopBootstrapStageResult['json'] + error?: string | null + } + | { type: 'log'; stage?: string | null; line: string } + | { type: 'complete'; marker: Record<string, unknown> } + | { type: 'failed'; stage?: string | null; error: string } + | { + type: 'unsupported-platform' + platform: string + activeRoot: string + installCommand: string + docsUrl: string + } + + +export interface HermesApiRequest { + path: string + method?: string + body?: unknown + timeoutMs?: number +} + +export interface HermesNotification { + title?: string + body?: string + silent?: boolean +} + +export interface HermesPreviewTarget { + binary?: boolean + byteSize?: number + kind: 'file' | 'url' + label: string + large?: boolean + language?: string + mimeType?: string + path?: string + previewKind?: 'binary' | 'html' | 'image' | 'text' + renderMode?: 'preview' | 'source' + source: string + url: string +} + +export interface HermesReadFileTextResult { + binary?: boolean + byteSize?: number + language?: string + mimeType?: string + path: string + text: string + truncated?: boolean +} + +export interface HermesPreviewWatch { + id: string + path: string +} + +export interface HermesReadDirEntry { + name: string + path: string + isDirectory: boolean +} + +export interface HermesReadDirResult { + entries: HermesReadDirEntry[] + error?: string +} + +export interface HermesPreviewFileChanged { + id: string + path: string + url: string +} + +export interface HermesSelectPathsOptions { + title?: string + defaultPath?: string + directories?: boolean + multiple?: boolean + filters?: Array<{ name: string; extensions: string[] }> +} + +export interface BackendExit { + code: number | null + signal: string | null +} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts new file mode 100644 index 000000000..530dba06c --- /dev/null +++ b/apps/desktop/src/hermes.ts @@ -0,0 +1,581 @@ +import { JsonRpcGatewayClient } from '@hermes/shared' + +import type { + ActionResponse, + ActionStatusResponse, + AnalyticsResponse, + AudioSpeakResponse, + AudioTranscriptionResponse, + AuxiliaryModelsResponse, + ConfigSchemaResponse, + CronJob, + CronJobCreatePayload, + CronJobUpdates, + ElevenLabsVoicesResponse, + EnvVarInfo, + HermesConfig, + HermesConfigRecord, + LogsResponse, + MessagingPlatformsResponse, + MessagingPlatformTestResponse, + MessagingPlatformUpdate, + ModelAssignmentRequest, + ModelAssignmentResponse, + ModelInfoResponse, + ModelOptionsResponse, + OAuthPollResponse, + OAuthProvidersResponse, + OAuthStartResponse, + OAuthSubmitResponse, + PaginatedSessions, + ProfileCreatePayload, + ProfileSetupCommand, + ProfileSoul, + ProfilesResponse, + SessionMessagesResponse, + SessionSearchResponse, + SkillInfo, + StatusResponse, + ToolsetConfig, + ToolsetInfo +} from '@/types/hermes' + +const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000 + +export type { + ActionResponse, + ActionStatusResponse, + AnalyticsDailyEntry, + AnalyticsModelEntry, + AnalyticsResponse, + AnalyticsSkillEntry, + AnalyticsSkillsSummary, + AnalyticsTotals, + AudioSpeakResponse, + AudioTranscriptionResponse, + AuxiliaryModelsResponse, + ConfigFieldSchema, + ConfigSchemaResponse, + CronJob, + CronJobCreatePayload, + CronJobSchedule, + CronJobUpdates, + ElevenLabsVoice, + ElevenLabsVoicesResponse, + EnvVarInfo, + GatewayReadyPayload, + HermesConfig, + HermesConfigRecord, + LogsResponse, + MessagingEnvVarInfo, + MessagingHomeChannel, + MessagingPlatformInfo, + MessagingPlatformsResponse, + MessagingPlatformTestResponse, + MessagingPlatformUpdate, + ModelAssignmentRequest, + ModelAssignmentResponse, + ModelInfoResponse, + ModelOptionProvider, + ModelOptionsResponse, + PaginatedSessions, + ProfileCreatePayload, + ProfileInfo, + ProfileSetupCommand, + ProfileSoul, + ProfilesResponse, + RpcEvent, + SessionCreateResponse, + SessionInfo, + SessionMessage, + SessionMessagesResponse, + SessionResumeResponse, + SessionRuntimeInfo, + SessionSearchResponse, + SessionSearchResult, + SkillInfo, + StatusResponse, + ToolsetConfig, + ToolsetInfo +} from '@/types/hermes' + +export class HermesGateway extends JsonRpcGatewayClient { + constructor() { + super({ + closedErrorMessage: 'Hermes gateway connection closed', + connectErrorMessage: 'Could not connect to Hermes gateway', + createRequestId: nextId => nextId, + notConnectedErrorMessage: 'Hermes gateway is not connected', + requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS + }) + } +} + +export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> { + const result = await window.hermesDesktop.api<PaginatedSessions>({ + path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + }) + + return { + ...result, + sessions: result.sessions.slice(0, limit), + offset: 0 + } +} + +export function searchSessions(query: string): Promise<SessionSearchResponse> { + return window.hermesDesktop.api<SessionSearchResponse>({ + path: `/api/sessions/search?q=${encodeURIComponent(query)}` + }) +} + +export function getSessionMessages(id: string): Promise<SessionMessagesResponse> { + return window.hermesDesktop.api<SessionMessagesResponse>({ + path: `/api/sessions/${encodeURIComponent(id)}/messages` + }) +} + +export function deleteSession(id: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + +export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> { + return window.hermesDesktop.api<{ ok: boolean; title: string }>({ + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { title } + }) +} + +export function getGlobalModelInfo(): Promise<ModelInfoResponse> { + return window.hermesDesktop.api<ModelInfoResponse>({ + path: '/api/model/info' + }) +} + +export function getStatus(): Promise<StatusResponse> { + return window.hermesDesktop.api<StatusResponse>({ + path: '/api/status' + }) +} + +export function getLogs(params: { + component?: string + file?: string + level?: string + lines?: number +}): Promise<LogsResponse> { + const query = new URLSearchParams() + + if (params.file) { + query.set('file', params.file) + } + + if (typeof params.lines === 'number') { + query.set('lines', String(params.lines)) + } + + if (params.level && params.level !== 'ALL') { + query.set('level', params.level) + } + + if (params.component && params.component !== 'all') { + query.set('component', params.component) + } + + const suffix = query.toString() + + return window.hermesDesktop.api<LogsResponse>({ + path: suffix ? `/api/logs?${suffix}` : '/api/logs' + }) +} + +export function getHermesConfig(): Promise<HermesConfig> { + return window.hermesDesktop.api<HermesConfig>({ + path: '/api/config' + }) +} + +export function getHermesConfigRecord(): Promise<HermesConfigRecord> { + return window.hermesDesktop.api<HermesConfigRecord>({ + path: '/api/config' + }) +} + +export function getHermesConfigDefaults(): Promise<HermesConfigRecord> { + return window.hermesDesktop.api<HermesConfigRecord>({ + path: '/api/config/defaults' + }) +} + +export function getHermesConfigSchema(): Promise<ConfigSchemaResponse> { + return window.hermesDesktop.api<ConfigSchemaResponse>({ + path: '/api/config/schema' + }) +} + +export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: '/api/config', + method: 'PUT', + body: { config } + }) +} + +export function getEnvVars(): Promise<Record<string, EnvVarInfo>> { + return window.hermesDesktop.api<Record<string, EnvVarInfo>>({ + path: '/api/env' + }) +} + +export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: '/api/env', + method: 'PUT', + body: { key, value } + }) +} + +export function validateProviderCredential( + key: string, + value: string +): Promise<{ ok: boolean; reachable: boolean; message: string }> { + return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string }>({ + path: '/api/providers/validate', + method: 'POST', + body: { key, value } + }) +} + +export function deleteEnvVar(key: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: '/api/env', + method: 'DELETE', + body: { key } + }) +} + +export function revealEnvVar(key: string): Promise<{ key: string; value: string }> { + return window.hermesDesktop.api<{ key: string; value: string }>({ + path: '/api/env/reveal', + method: 'POST', + body: { key } + }) +} + +export function listOAuthProviders(): Promise<OAuthProvidersResponse> { + return window.hermesDesktop.api<OAuthProvidersResponse>({ + path: '/api/providers/oauth' + }) +} + +export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> { + return window.hermesDesktop.api<OAuthStartResponse>({ + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, + method: 'POST', + body: {} + }) +} + +export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> { + return window.hermesDesktop.api<OAuthSubmitResponse>({ + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, + method: 'POST', + body: { session_id: sessionId, code } + }) +} + +export function pollOAuthSession(providerId: string, sessionId: string): Promise<OAuthPollResponse> { + return window.hermesDesktop.api<OAuthPollResponse>({ + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` + }) +} + +export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, + method: 'DELETE' + }) +} + +export function getSkills(): Promise<SkillInfo[]> { + return window.hermesDesktop.api<SkillInfo[]>({ + path: '/api/skills' + }) +} + +export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + path: '/api/skills/toggle', + method: 'PUT', + body: { name, enabled } + }) +} + +export function getToolsets(): Promise<ToolsetInfo[]> { + return window.hermesDesktop.api<ToolsetInfo[]>({ + path: '/api/tools/toolsets' + }) +} + +export function toggleToolset( + name: string, + enabled: boolean +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + path: `/api/tools/toolsets/${encodeURIComponent(name)}`, + method: 'PUT', + body: { enabled } + }) +} + +export function getToolsetConfig(name: string): Promise<ToolsetConfig> { + return window.hermesDesktop.api<ToolsetConfig>({ + path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` + }) +} + +export function selectToolsetProvider( + name: string, + provider: string +): Promise<{ ok: boolean; name: string; provider: string }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; provider: string }>({ + path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, + method: 'PUT', + body: { provider } + }) +} + +export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> { + return window.hermesDesktop.api<MessagingPlatformsResponse>({ + path: '/api/messaging/platforms' + }) +} + +export function updateMessagingPlatform( + platformId: string, + body: MessagingPlatformUpdate +): Promise<{ ok: boolean; platform: string }> { + return window.hermesDesktop.api<{ ok: boolean; platform: string }>({ + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, + method: 'PUT', + body + }) +} + +export function testMessagingPlatform(platformId: string): Promise<MessagingPlatformTestResponse> { + return window.hermesDesktop.api<MessagingPlatformTestResponse>({ + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`, + method: 'POST' + }) +} + +export function getCronJobs(): Promise<CronJob[]> { + return window.hermesDesktop.api<CronJob[]>({ + path: '/api/cron/jobs' + }) +} + +export function getCronJob(jobId: string): Promise<CronJob> { + return window.hermesDesktop.api<CronJob>({ + path: `/api/cron/jobs/${encodeURIComponent(jobId)}` + }) +} + +export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> { + return window.hermesDesktop.api<CronJob>({ + path: '/api/cron/jobs', + method: 'POST', + body + }) +} + +export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> { + return window.hermesDesktop.api<CronJob>({ + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'PUT', + body: { updates } + }) +} + +export function pauseCronJob(jobId: string): Promise<CronJob> { + return window.hermesDesktop.api<CronJob>({ + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, + method: 'POST' + }) +} + +export function resumeCronJob(jobId: string): Promise<CronJob> { + return window.hermesDesktop.api<CronJob>({ + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, + method: 'POST' + }) +} + +export function triggerCronJob(jobId: string): Promise<CronJob> { + return window.hermesDesktop.api<CronJob>({ + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, + method: 'POST' + }) +} + +export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'DELETE' + }) +} + +export function getProfiles(): Promise<ProfilesResponse> { + return window.hermesDesktop.api<ProfilesResponse>({ + path: '/api/profiles' + }) +} + +export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { + return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({ + path: '/api/profiles', + method: 'POST', + body + }) +} + +export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> { + return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({ + path: `/api/profiles/${encodeURIComponent(name)}`, + method: 'PATCH', + body: { new_name: newName } + }) +} + +export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> { + return window.hermesDesktop.api<{ ok: boolean; path: string }>({ + path: `/api/profiles/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function getProfileSoul(name: string): Promise<ProfileSoul> { + return window.hermesDesktop.api<ProfileSoul>({ + path: `/api/profiles/${encodeURIComponent(name)}/soul` + }) +} + +export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/soul`, + method: 'PUT', + body: { content } + }) +} + +export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> { + return window.hermesDesktop.api<ProfileSetupCommand>({ + path: `/api/profiles/${encodeURIComponent(name)}/setup-command` + }) +} + +export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> { + return window.hermesDesktop.api<AnalyticsResponse>({ + path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` + }) +} + +export function getGlobalModelOptions(): Promise<ModelOptionsResponse> { + return window.hermesDesktop.api<ModelOptionsResponse>({ + path: '/api/model/options' + }) +} + +export interface RecommendedDefaultModel { + provider: string + model: string + /** True/false for Nous (free vs paid tier); null for other providers. */ + free_tier: boolean | null +} + +// Recommended default model for a freshly-authenticated provider. Mirrors the +// curation `hermes model` does — for Nous it honors the free/paid tier so a +// free user gets a free model instead of a paid default. +export function getRecommendedDefaultModel(provider: string): Promise<RecommendedDefaultModel> { + return window.hermesDesktop.api<RecommendedDefaultModel>({ + path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` + }) +} + +export function setGlobalModel( + provider: string, + model: string +): Promise<{ ok: boolean; provider: string; model: string }> { + return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({ + path: '/api/model/set', + method: 'POST', + body: { + scope: 'main', + provider, + model + } + }) +} + +export function getAuxiliaryModels(): Promise<AuxiliaryModelsResponse> { + return window.hermesDesktop.api<AuxiliaryModelsResponse>({ + path: '/api/model/auxiliary' + }) +} + +export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelAssignmentResponse> { + return window.hermesDesktop.api<ModelAssignmentResponse>({ + path: '/api/model/set', + method: 'POST', + body + }) +} + +export function restartGateway(): Promise<ActionResponse> { + return window.hermesDesktop.api<ActionResponse>({ + path: '/api/gateway/restart', + method: 'POST' + }) +} + +export function updateHermes(): Promise<ActionResponse> { + return window.hermesDesktop.api<ActionResponse>({ + path: '/api/hermes/update', + method: 'POST' + }) +} + +export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> { + return window.hermesDesktop.api<ActionStatusResponse>({ + path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` + }) +} + +export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> { + return window.hermesDesktop.api<AudioTranscriptionResponse>({ + path: '/api/audio/transcribe', + method: 'POST', + body: { + data_url: dataUrl, + mime_type: mimeType + } + }) +} + +export function speakText(text: string): Promise<AudioSpeakResponse> { + return window.hermesDesktop.api<AudioSpeakResponse>({ + path: '/api/audio/speak', + method: 'POST', + body: { text } + }) +} + +export function getElevenLabsVoices(): Promise<ElevenLabsVoicesResponse> { + return window.hermesDesktop.api<ElevenLabsVoicesResponse>({ + path: '/api/audio/elevenlabs/voices' + }) +} diff --git a/apps/desktop/src/hooks/use-media-query.ts b/apps/desktop/src/hooks/use-media-query.ts new file mode 100644 index 000000000..aa368dfb9 --- /dev/null +++ b/apps/desktop/src/hooks/use-media-query.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +export const matchesQuery = (query: string) => + typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(query).matches + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => matchesQuery(query)) + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + return + } + + const mql = window.matchMedia(query) + const onChange = () => setMatches(mql.matches) + + setMatches(mql.matches) + mql.addEventListener('change', onChange) + + return () => mql.removeEventListener('change', onChange) + }, [query]) + + return matches +} diff --git a/apps/desktop/src/hooks/use-mobile.ts b/apps/desktop/src/hooks/use-mobile.ts new file mode 100644 index 000000000..9beed4a9a --- /dev/null +++ b/apps/desktop/src/hooks/use-mobile.ts @@ -0,0 +1,3 @@ +import { useMediaQuery } from './use-media-query' + +export const useIsMobile = () => useMediaQuery(`(max-width: ${768 / 16 - 1 / 16}rem)`) diff --git a/apps/desktop/src/hooks/use-resize-observer.ts b/apps/desktop/src/hooks/use-resize-observer.ts new file mode 100644 index 000000000..b350a367d --- /dev/null +++ b/apps/desktop/src/hooks/use-resize-observer.ts @@ -0,0 +1,38 @@ +import { type RefObject, useLayoutEffect, useRef } from 'react' + +export function useResizeObserver(onResize: () => void, ...refs: readonly RefObject<Element | null>[]) { + const refsRef = useRef(refs) + refsRef.current = refs + + useLayoutEffect(() => { + if (typeof ResizeObserver === 'undefined') { + onResize() + + return + } + + const observer = new ResizeObserver(() => onResize()) + let observed = false + + for (const ref of refsRef.current) { + const element = ref.current + + if (!element) { + continue + } + + observer.observe(element) + observed = true + } + + if (!observed) { + observer.disconnect() + + return + } + + onResize() + + return () => observer.disconnect() + }, [onResize]) +} diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts new file mode 100644 index 000000000..20329d854 --- /dev/null +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -0,0 +1,708 @@ +import { describe, expect, it } from 'vitest' + +import type { ChatMessage, ChatMessagePart } from './chat-messages' +import { + appendAssistantTextPart, + chatMessageText, + preserveLocalAssistantErrors, + renderMediaTags, + toChatMessages, + upsertToolPart +} from './chat-messages' + +describe('toChatMessages', () => { + it('keeps a turn with interleaved tool-only rows in a single bubble', () => { + const messages = toChatMessages([ + { role: 'assistant', content: 'Planning.', timestamp: 1 }, + { + role: 'assistant', + content: '', + timestamp: 2, + tool_calls: [{ id: 'tc', function: { name: 'terminal', arguments: '{}' } }] + }, + { role: 'assistant', content: 'Done.', timestamp: 3 } + ]) + + expect(messages).toHaveLength(1) + expect(messages[0].parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text']) + expect(chatMessageText(messages[0])).toBe('Planning.Done.') + }) + + it('keeps assistant tool-call iterations in one loaded assistant bubble', () => { + const messages = toChatMessages([ + { role: 'user', content: 'check this repo', timestamp: 1 }, + { + role: 'assistant', + content: "Let me also check if there's a top-level lint workflow.", + timestamp: 2, + tool_calls: [{ id: 'tc-1', function: { name: 'search_files', arguments: '{"path":".github"}' } }] + }, + { + role: 'tool', + tool_call_id: 'tc-1', + tool_name: 'search_files', + content: '{"error":"Path not found: /repo/.github"}', + timestamp: 3 + }, + { + role: 'assistant', + content: 'No CI in this repo. Build is enough.', + timestamp: 4, + tool_calls: [{ id: 'tc-2', function: { name: 'terminal', arguments: '{"command":"git status --short"}' } }] + }, + { + role: 'tool', + tool_call_id: 'tc-2', + tool_name: 'terminal', + content: '{"output":"M src/ui/components/image-distortion.tsx\\n","exit_code":0}', + timestamp: 5 + }, + { role: 'assistant', content: 'Now let me check git status and commit.', timestamp: 6 } + ]) + + const assistantMessages = messages.filter(message => message.role === 'assistant') + + expect(assistantMessages).toHaveLength(1) + expect(assistantMessages[0].parts.filter(part => part.type === 'tool-call')).toHaveLength(2) + expect(chatMessageText(assistantMessages[0])).toContain("Let me also check if there's a top-level lint workflow.") + expect(chatMessageText(assistantMessages[0])).toContain('Now let me check git status and commit.') + }) + + it('hides attached context payloads from user message display', () => { + const [message] = toChatMessages([ + { + role: 'user', + content: + 'what is this file\n\n--- Attached Context ---\n\n📄 @file:tsconfig.tsbuildinfo (981 tokens)\n```json\n{"root":["./src/main.tsx"]}\n```', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('@file:tsconfig.tsbuildinfo\n\nwhat is this file') + }) + + it('renders MEDIA tags as assistant attachment links', () => { + const [message] = toChatMessages([ + { + role: 'assistant', + content: "MEDIA:/Users/brooklyn/.hermes/cache/audio/tts_20260501_222725.mp3\n\nhow's that sound?", + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe( + "[Audio: tts_20260501_222725.mp3](#media:%2FUsers%2Fbrooklyn%2F.hermes%2Fcache%2Faudio%2Ftts_20260501_222725.mp3)\n\nhow's that sound?" + ) + }) + + it('coerces non-string message content without throwing', () => { + const [message] = toChatMessages([ + { + content: { + text: 'hello from object content' + }, + role: 'assistant', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('hello from object content') + }) + + it('applies attached-context filtering when user content is object-shaped', () => { + const [message] = toChatMessages([ + { + content: { + text: 'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```' + }, + role: 'user', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook') + }) +}) + +describe('renderMediaTags', () => { + it('renders standalone and inline MEDIA tags as links', () => { + expect(renderMediaTags('here\nMEDIA:/tmp/voice.mp3\nthere')).toBe( + 'here\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)\nthere' + ) + expect(renderMediaTags('audio: MEDIA:/tmp/voice.mp3 done')).toBe( + 'audio: [Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3) done' + ) + expect(renderMediaTags('MEDIA:/tmp/demo.mp4')).toBe('[Video: demo.mp4](#media:%2Ftmp%2Fdemo.mp4)') + }) + + it('renders streamed assistant media once the tag is complete', () => { + const parts = appendAssistantTextPart(appendAssistantTextPart([], 'ok\nMEDIA:'), '/tmp/voice.mp3') + const text = chatMessageText({ id: 'a', role: 'assistant', parts }) + + expect(text).toBe('ok\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)') + }) +}) + +describe('preserveLocalAssistantErrors', () => { + it('preserves a local user+error pair when hydration omits the failed turn', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'earlier', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'earlier', type: 'text' }], + role: 'user' + }, + { + id: 'user-123', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-error-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual(['stored-user', 'user-123', 'assistant-error-1']) + expect(merged[2]?.error).toBe('OpenRouter 403') + }) + + it('does not keep orphan local user turns when there is no inline assistant error', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'earlier', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + ...nextMessages, + { + id: 'user-123', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual(['stored-user']) + }) + + it('does not duplicate local user when stored history already has equivalent text', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'optimistic-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-error-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual(['stored-user', 'assistant-error-1']) + }) + + it('keeps local user when only older history has equivalent text', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'older-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + }, + { + id: 'older-assistant', + parts: [{ text: 'hello', type: 'text' }], + role: 'assistant' + }, + { + id: 'tail-user', + parts: [{ text: 'different prompt', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'optimistic-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-error-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual([ + 'older-user', + 'older-assistant', + 'tail-user', + 'optimistic-user', + 'assistant-error-1' + ]) + }) + + it('keeps local assistant error when hydrated message reuses same id', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'user-1', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + }, + { + id: 'assistant-stream-1', + parts: [{ text: '', type: 'text' }], + role: 'assistant' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'user-1', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-stream-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + const assistant = merged.find(message => message.id === 'assistant-stream-1') + + expect(assistant?.error).toBe('OpenRouter 403') + expect(assistant?.pending).toBe(false) + }) +}) + +describe('upsertToolPart', () => { + it('preserves inline diffs from tool completion events', () => { + const parts = upsertToolPart( + [], + { + inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new', + name: 'patch', + tool_id: 'tool-1' + }, + 'complete' + ) + + const [part] = parts + + expect(part?.type).toBe('tool-call') + expect(part && 'result' in part ? part.result : undefined).toMatchObject({ + inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + }) + }) + + it('keeps live todo rows stable across sparse progress payloads', () => { + const first = upsertToolPart( + [], + { + name: 'todo', + todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }], + tool_id: 'todo-1' + }, + 'running' + ) + + const progressed = upsertToolPart( + first, + { + name: 'todo', + preview: 'updating plan', + tool_id: 'todo-1' + }, + 'running' + ) + + const [part] = progressed + const args = part && 'args' in part ? (part.args as Record<string, unknown>) : {} + + expect(args.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }]) + }) + + it('archives todo state on completion and accepts explicit empty clears', () => { + const started = upsertToolPart( + [], + { + name: 'todo', + todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }], + tool_id: 'todo-1' + }, + 'running' + ) + + const completed = upsertToolPart( + started, + { + name: 'todo', + tool_id: 'todo-1' + }, + 'complete' + ) + + const cleared = upsertToolPart( + completed, + { + name: 'todo', + todos: [], + tool_id: 'todo-1' + }, + 'complete' + ) + + const completedResult = + completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {} + + const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {} + + expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }]) + expect(clearedResult.todos).toEqual([]) + }) + + it('keeps parallel same-name tools distinct without explicit ids', () => { + const startedTokyo = upsertToolPart( + [], + { + context: 'tokyo weather', + name: 'web_search' + }, + 'running' + ) + + const startedReykjavik = upsertToolPart( + startedTokyo, + { + context: 'reykjavik weather', + name: 'web_search' + }, + 'running' + ) + + const completedTokyo = upsertToolPart( + startedReykjavik, + { + context: 'tokyo weather', + message: 'tokyo done', + name: 'web_search', + summary: 'Did 5 searches' + }, + 'complete' + ) + + const completedBoth = upsertToolPart( + completedTokyo, + { + context: 'reykjavik weather', + message: 'reykjavik done', + name: 'web_search', + summary: 'Did 5 searches' + }, + 'complete' + ) + + const webParts = completedBoth.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + const contexts = webParts.map(part => String((part.args as Record<string, unknown>)?.context || '')) + + const summaries = webParts.map(part => { + if (!('result' in part) || !part.result || typeof part.result !== 'object') { + return '' + } + + return String((part.result as Record<string, unknown>).summary || '') + }) + + expect(webParts).toHaveLength(2) + expect(contexts).toEqual(['tokyo weather', 'reykjavik weather']) + expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches']) + }) + + it('preserves query args when completion payload omits context', () => { + const started = upsertToolPart( + [], + { + context: 'auckland weather today and tomorrow forecast', + name: 'web_search', + tool_id: 'search-1' + }, + 'running' + ) + + const completed = upsertToolPart( + started, + { + duration_s: 1.1, + name: 'web_search', + summary: 'Did 5 searches in 1.1s', + tool_id: 'search-1' + }, + 'complete' + ) + + const [part] = completed + + expect(part?.type).toBe('tool-call') + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({ + context: 'auckland weather today and tomorrow forecast' + }) + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({ + summary: 'Did 5 searches in 1.1s' + }) + }) + + it('does not append phantom same-name tool rows for id-less progress updates', () => { + const startedA = upsertToolPart( + [], + { + context: 'reykjavik weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const startedB = upsertToolPart( + startedA, + { + context: 'kathmandu weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const progressed = upsertToolPart( + startedB, + { + name: 'web_search' + }, + 'running' + ) + + const webParts = progressed.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + expect(webParts).toHaveLength(2) + }) + + it('matches id-less live starts with later identified completions', () => { + const started = upsertToolPart( + [], + { + context: 'asuncion paraguay weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const completed = upsertToolPart( + started, + { + context: 'asuncion paraguay weather today and tomorrow forecast', + duration_s: 1.1, + name: 'web_search', + summary: 'Did 5 searches in 1.1s', + tool_id: 'search-asuncion' + }, + 'complete' + ) + + const webParts = completed.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + expect(webParts).toHaveLength(1) + expect(webParts[0].toolCallId).toBe('search-asuncion') + expect(webParts[0].result).toMatchObject({ summary: 'Did 5 searches in 1.1s' }) + }) + + it('matches id-less live starts with later identified progress updates', () => { + const started = upsertToolPart( + [], + { + context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const progressed = upsertToolPart( + started, + { + context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast', + name: 'web_search', + tool_id: 'search-reykjavik' + }, + 'running' + ) + + const webParts = progressed.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + expect(webParts).toHaveLength(1) + expect(webParts[0].toolCallId).toBe('search-reykjavik') + }) + + it('reconciles preview-first progress rows with later stable-id starts', () => { + const progressA = upsertToolPart( + [], + { + name: 'web_search', + preview: 'tokyo weather' + }, + 'running' + ) + + const progressB = upsertToolPart( + progressA, + { + name: 'web_search', + preview: 'reykjavik weather' + }, + 'running' + ) + + const startedA = upsertToolPart( + progressB, + { + args: { query: 'tokyo weather' }, + name: 'web_search', + tool_id: 'search-tokyo' + }, + 'running' + ) + + const startedB = upsertToolPart( + startedA, + { + args: { query: 'reykjavik weather' }, + name: 'web_search', + tool_id: 'search-reykjavik' + }, + 'running' + ) + + const completedA = upsertToolPart( + startedB, + { + name: 'web_search', + summary: 'Did 5 searches', + tool_id: 'search-tokyo' + }, + 'complete' + ) + + const completedB = upsertToolPart( + completedA, + { + name: 'web_search', + summary: 'Did 5 searches', + tool_id: 'search-reykjavik' + }, + 'complete' + ) + + const webParts = completedB + .filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + .map(part => ({ + id: part.toolCallId, + query: String((part.args as Record<string, unknown>)?.query || ''), + summary: + part.result && typeof part.result === 'object' + ? String((part.result as Record<string, unknown>).summary || '') + : '' + })) + + expect(webParts).toEqual([ + { id: 'search-tokyo', query: 'tokyo weather', summary: 'Did 5 searches' }, + { id: 'search-reykjavik', query: 'reykjavik weather', summary: 'Did 5 searches' } + ]) + }) + + it('uses structured live tool args for titles before hydrate', () => { + const started = upsertToolPart( + [], + { + args: { search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' }, + name: 'web_search', + tool_id: 'search-bishkek' + }, + 'running' + ) + + const [part] = started + + expect(part?.type).toBe('tool-call') + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({ + search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' + }) + }) + + it('keeps structured live tool results before hydrate', () => { + const completed = upsertToolPart( + [], + { + args: { query: 'suva weather' }, + name: 'web_search', + result: { data: { web: [{ title: 'Suva forecast', url: 'https://example.test', description: 'Sunny' }] } }, + summary: 'Did 1 search in 0.5s', + tool_id: 'search-suva' + }, + 'complete' + ) + + const [part] = completed + + expect(part?.type).toBe('tool-call') + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({ + data: { web: [{ title: 'Suva forecast' }] }, + summary: 'Did 1 search in 0.5s' + }) + }) +}) diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts new file mode 100644 index 000000000..f8a32d9e5 --- /dev/null +++ b/apps/desktop/src/lib/chat-messages.ts @@ -0,0 +1,878 @@ +import type { ThreadMessageLike } from '@assistant-ui/react' + +import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' +import { parseTodos } from '@/lib/todos' +import type { SessionMessage, UsageStats } from '@/types/hermes' + +export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number] + +export type ChatMessage = { + id: string + role: SessionMessage['role'] + parts: ChatMessagePart[] + timestamp?: number + pending?: boolean + error?: string + branchGroupId?: string + hidden?: boolean + /** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */ + attachmentRefs?: string[] +} + +export type GatewayEventPayload = { + text?: string + rendered?: string + status?: string + message?: string + id?: string + name?: string + tool_id?: string + tool_call_id?: string + args?: unknown + arguments?: unknown + context?: string + input?: unknown + preview?: string + result?: unknown + summary?: string + error?: string | boolean + inline_diff?: string + duration_s?: number + todos?: unknown + model?: string + provider?: string + reasoning_effort?: string + service_tier?: string + fast?: boolean + running?: boolean + cwd?: string + branch?: string + credential_warning?: string + personality?: string + usage?: Partial<UsageStats> + // clarify.request + request_id?: string + question?: string + choices?: string[] | null +} + +export function textPart(text: string): ChatMessagePart { + return { type: 'text', text } +} + +export function reasoningPart(text: string): ChatMessagePart { + return { type: 'reasoning', text } +} + +const MEDIA_LINE_RE = /(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g + +const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g + +function unquoteMediaPath(value: string): string { + const trimmed = value.trim() + const quote = trimmed[0] + + return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed +} + +function mediaLink(value: string): string { + const path = unquoteMediaPath(value) + + return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})` +} + +export function renderMediaTags(text: string): string { + return text + .replace( + MEDIA_LINE_RE, + (_match, lead: string, value: string, trailer: string) => `${lead}${mediaLink(value)}${trailer}` + ) + .replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value)) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') +} + +export function assistantTextPart(text: string): ChatMessagePart { + return textPart(renderMediaTags(text)) +} + +export function chatMessageText(message: ChatMessage): string { + return message.parts + .filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text') + .map(part => part.text) + .join('') +} + +const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/ +const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/ +const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g + +function textFromUnknown(value: unknown, depth = 0): string { + if (typeof value === 'string') { + return value + } + + if (value === null || value === undefined) { + return '' + } + + if (depth > 2) { + return '' + } + + if (Array.isArray(value)) { + return value.map(item => textFromUnknown(item, depth + 1)).join('') + } + + if (typeof value === 'object') { + const row = value as Record<string, unknown> + const textValue = row.text ?? row.output_text ?? row.content ?? row.message + const nestedText = textFromUnknown(textValue, depth + 1) + + if (nestedText) { + return nestedText + } + + try { + return JSON.stringify(value) + } catch { + return '' + } + } + + return String(value) +} + +function displayContentForMessage(role: SessionMessage['role'], content: unknown): string { + const textContent = textFromUnknown(content) + + if (role !== 'user') { + return textContent + } + + const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE) + + if (!marker || marker.index === undefined) { + return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim() + } + + const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim() + const attachedContext = textContent.slice(marker.index + marker[0].length) + const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))] + + return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText +} + +export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + const next = [...parts] + const last = next.at(-1) + + if (last?.type === 'text') { + next[next.length - 1] = { ...last, text: `${last.text}${delta}` } + + return next + } + + next.push(textPart(delta)) + + return next +} + +export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + const next = appendTextPart(parts, delta) + const last = next.at(-1) + + if (last?.type === 'text') { + const current = last.text + + const deltaMayContainMedia = + delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:') + + const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:') + const nextText = needsMediaPass ? renderMediaTags(current) : current + next[next.length - 1] = nextText === current ? last : { ...last, text: nextText } + } + + return next +} + +export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + const next = [...parts] + const last = next.at(-1) + + if (last?.type === 'reasoning') { + next[next.length - 1] = { ...last, text: `${last.text}${delta}` } + + return next + } + + next.push(reasoningPart(delta)) + + return next +} + +export function hasToolPart(message: ChatMessage): boolean { + return message.parts.some(part => part.type === 'tool-call') +} + +function toolId(payload: GatewayEventPayload | undefined): string { + return payload?.tool_id || payload?.tool_call_id || payload?.id || '' +} + +let liveToolCounter = 0 + +function nextLiveToolId(name: string): string { + liveToolCounter += 1 + + return `live-tool:${name}:${liveToolCounter}` +} + +function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string { + for (const key of keys) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return '' +} + +function normalizeToolMatchValue(value: string): string { + return value.trim().toLowerCase() +} + +function collectToolMatchValues(query: string, context: string, preview: string): string[] { + return [...new Set([query, context, preview].map(normalizeToolMatchValue).filter(Boolean))] +} + +function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] { + const payloadArgs = liveToolArgs(payload) + const query = firstStringField(payloadArgs, ['search_term', 'query']) + const context = typeof payload?.context === 'string' ? payload.context.trim() : '' + const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : '' + + return collectToolMatchValues(query, context, preview) +} + +function toolPartMatchValues(part: ChatMessagePart): string[] { + if (part.type !== 'tool-call' || !part.args || typeof part.args !== 'object') { + return [] + } + + const args = part.args as Record<string, unknown> + const query = firstStringField(args, ['search_term', 'query']) + const context = typeof args.context === 'string' ? args.context.trim() : '' + const preview = typeof args.preview === 'string' ? args.preview.trim() : '' + + return collectToolMatchValues(query, context, preview) +} + +function hasToolMatchOverlap(left: string[], right: string[]): boolean { + if (!left.length || !right.length) { + return false + } + + const rightSet = new Set(right) + + return left.some(value => rightSet.has(value)) +} + +function findToolPartIndex( + parts: ChatMessagePart[], + name: string, + stableId: string, + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete' +): number { + const matchValues = toolPayloadMatchValues(payload) + const overlaps = (index: number) => hasToolMatchOverlap(matchValues, toolPartMatchValues(parts[index])) + + if (stableId) { + const stableIndex = parts.findIndex(part => part.type === 'tool-call' && part.toolCallId === stableId) + + if (stableIndex >= 0) { + return stableIndex + } + + // Some live streams start without an id, then complete with one. Fall + // through to pending same-name/context matching so the completion updates + // the synthetic live row instead of appending a duplicate completed row. + if (phase === 'running' && !matchValues.length) { + return -1 + } + } + + const pendingIndices = parts + .map((part, index) => ({ part, index })) + .filter(({ part }) => part.type === 'tool-call' && part.toolName === name && part.result === undefined) + .map(({ index }) => index) + + if (pendingIndices.length === 0) { + return -1 + } + + if (matchValues.length) { + const contextualIndex = pendingIndices.find(overlaps) + + if (contextualIndex !== undefined) { + return contextualIndex + } + } + + if (pendingIndices.length === 1) { + const [singlePendingIndex] = pendingIndices + + if (phase === 'running' && matchValues.length && !overlaps(singlePendingIndex)) { + return stableId ? singlePendingIndex : -1 + } + + return singlePendingIndex + } + + // Completion events without stable IDs frequently arrive after multiple + // same-name starts (parallel tool calls). Resolve them oldest-first so we + // don't collapse an entire burst into a single row. + if (phase === 'complete') { + return pendingIndices[0] + } + + if (stableId) { + return pendingIndices[0] + } + + // For progress/running events with no stable id, update the most-recent + // pending same-name tool instead of creating a phantom extra row. + return pendingIndices.at(-1) ?? -1 +} + +// Carry todo state across sparse progress payloads: if this todo event lacks +// a `todos` field, fall back to whatever we previously stored on the part. +function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]): { todos: unknown } | undefined { + if (payload && Object.hasOwn(payload, 'todos')) { + const next = parseTodos(payload.todos) + + return next === null ? undefined : { todos: next } + } + + if (payload?.name !== 'todo') { + return undefined + } + + for (const p of prev) { + const carried = parseTodos(recordFromUnknown(p)?.todos) + + if (carried !== null) { + return { todos: carried } + } + } + + return undefined +} + +function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> { + const prev = parseMaybeJsonObject(prevArgs) + const eventArgs = liveToolArgs(payload) + + return { + ...prev, + ...eventArgs, + ...(payload?.context ? { context: payload.context } : {}), + ...(payload?.preview ? { preview: payload.preview } : {}), + ...carryTodos(payload, prevArgs) + } +} + +function toolResult( + payload: GatewayEventPayload | undefined, + prevResult?: unknown, + prevArgs?: unknown +): Record<string, unknown> { + const parsedResult = parseMaybeJsonObject(payload?.result) + + return { + ...parsedResult, + ...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}), + ...(payload?.summary ? { summary: payload.summary } : {}), + ...(payload?.message ? { message: payload.message } : {}), + ...(payload?.preview ? { preview: payload.preview } : {}), + ...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}), + ...carryTodos(payload, prevResult, prevArgs), + ...(payload?.error ? { error: payload.error } : {}) + } +} + +export function upsertToolPart( + parts: ChatMessagePart[], + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete' +): ChatMessagePart[] { + const stableId = toolId(payload) + const name = payload?.name || 'tool' + const next = [...parts] + + const index = findToolPartIndex(next, name, stableId, payload, phase) + + const prev = index >= 0 ? next[index] : null + const prevArgs = prev && 'args' in prev ? prev.args : undefined + const prevResult = prev && 'result' in prev ? prev.result : undefined + const args = toolArgs(payload, prevArgs) + + const id = + stableId || + (prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') || + nextLiveToolId(name) + + const base = { + type: 'tool-call' as const, + toolCallId: id, + toolName: name, + args: args as never, + argsText: JSON.stringify(args), + ...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) }) + } satisfies ChatMessagePart + + if (index === -1) { + return [...next, base] + } + + next[index] = { ...next[index], ...base } + + return next +} + +function recordFromUnknown(value: unknown): Record<string, unknown> | null { + return value && typeof value === 'object' ? (value as Record<string, unknown>) : null +} + +function parseMaybeJsonObject(value: unknown): Record<string, unknown> { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record<string, unknown> + } + + if (typeof value !== 'string' || !value.trim()) { + return {} + } + + try { + const parsed = JSON.parse(value) + + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {} + } catch { + return {} + } +} + +function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> { + for (const value of values) { + const parsed = parseMaybeJsonObject(value) + + if (Object.keys(parsed).length > 0) { + return parsed + } + } + + return {} +} + +function liveToolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> { + const direct = firstNonEmptyObject(payload?.args, payload?.arguments) + const input = firstNonEmptyObject(payload?.input) + const fn = recordFromUnknown(input.function) + + const nested = firstNonEmptyObject( + input.args, + input.arguments, + input.parameters, + input.input, + fn?.arguments, + fn?.args, + fn?.parameters + ) + + return { + ...input, + ...nested, + ...direct + } +} + +function parseStoredToolResult(content: unknown): unknown { + if (content && typeof content === 'object') { + return content + } + + const textContent = textFromUnknown(content) + + if (!textContent.trim()) { + return '' + } + + try { + return JSON.parse(textContent) + } catch { + return textContent + } +} + +function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessagePart { + const row = recordFromUnknown(call) ?? {} + const fn = recordFromUnknown(row.function) + const id = String(row.id || row.tool_call_id || `stored-tool-${fallbackIndex}`) + + const toolName = String( + row.name || row.tool_name || fn?.name || (recordFromUnknown(row.input)?.name as string | undefined) || 'tool' + ) + + const args = firstNonEmptyObject(fn?.arguments, row.arguments, row.args, row.input) + + return { + type: 'tool-call', + toolCallId: id, + toolName, + args: args as never, + argsText: Object.keys(args).length ? JSON.stringify(args) : '' + } +} + +function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean { + const toolCallId = toolMessage.tool_call_id || undefined + const toolName = toolMessage.tool_name || toolMessage.name || 'tool' + const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + + if (message.role !== 'assistant') { + continue + } + + const partIndex = message.parts.findIndex( + part => + part.type === 'tool-call' && + ((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName)) + ) + + if (partIndex < 0) { + continue + } + + const parts = [...message.parts] + const existing = parts[partIndex] + parts[partIndex] = { + ...existing, + result: parseStoredToolResult(content), + isError: false + } as ChatMessagePart + messages[i] = { ...message, parts } + + return true + } + + return false +} + +function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null { + const toolCallId = toolMessage.tool_call_id || undefined + const toolName = toolMessage.tool_name || toolMessage.name || 'tool' + const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name + + const partIndex = parts.findIndex( + part => + part.type === 'tool-call' && + ((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName)) + ) + + if (partIndex < 0) { + return null + } + + const next = [...parts] + const existing = next[partIndex] + next[partIndex] = { + ...existing, + result: parseStoredToolResult(content), + isError: false + } as ChatMessagePart + + return next +} + +function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart { + const name = toolMessage.tool_name || toolMessage.name || 'tool' + const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '') + const args = context ? { context } : {} + + return { + type: 'tool-call', + toolCallId: toolMessage.tool_call_id || `stored-tool-message-${fallbackIndex}`, + toolName: name, + args: args as never, + argsText: Object.keys(args).length ? JSON.stringify(args) : '', + result: context ? { context } : {}, + isError: false + } +} + +function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] { + const seen = new Set<string>() + + return messages.map(message => { + let changed = false + + const parts = message.parts.map((part, index) => { + if (part.type !== 'tool-call') { + return part + } + + const id = part.toolCallId || `${message.id}-tool-${index}` + + if (!seen.has(id)) { + seen.add(id) + + if (part.toolCallId) { + return part + } + + changed = true + + return { ...part, toolCallId: id } as ChatMessagePart + } + + changed = true + const uniqueId = `${id}-${message.id}-${index}` + seen.add(uniqueId) + + return { ...part, toolCallId: uniqueId } as ChatMessagePart + }) + + return changed ? { ...message, parts } : message + }) +} + +export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { + const result: ChatMessage[] = [] + let pendingToolParts: ChatMessagePart[] = [] + let pendingToolTimestamp: number | undefined + let activeAssistantIndex: null | number = null + + const clearPendingTools = () => { + pendingToolParts = [] + pendingToolTimestamp = undefined + } + + const appendPartsToActiveAssistant = (parts: ChatMessagePart[], timestamp?: number): boolean => { + if (activeAssistantIndex === null) { + return false + } + + const active = result[activeAssistantIndex] + + if (!active || active.role !== 'assistant') { + activeAssistantIndex = null + + return false + } + + active.parts = [...active.parts, ...parts] + active.timestamp = timestamp ?? active.timestamp + + return true + } + + const flushPendingTools = (index: number) => { + if (!pendingToolParts.length) { + return + } + + if (!appendPartsToActiveAssistant(pendingToolParts, pendingToolTimestamp)) { + result.push({ + id: `${pendingToolTimestamp || Date.now()}-${index}-tools`, + role: 'assistant', + parts: pendingToolParts, + timestamp: pendingToolTimestamp + }) + activeAssistantIndex = result.length - 1 + } + + clearPendingTools() + } + + messages.forEach((message, index) => { + if (message.role === 'tool') { + const updatedPendingToolParts = applyStoredToolResultToParts(pendingToolParts, message) + + if (updatedPendingToolParts) { + pendingToolParts = updatedPendingToolParts + + return + } + + if (applyStoredToolResult(result, message)) { + return + } + + pendingToolParts = [...pendingToolParts, storedToolMessagePart(message, index)] + pendingToolTimestamp ??= message.timestamp + + return + } + + const content = message.content || message.text || message.context || message.name + const displayContent = displayContentForMessage(message.role, content) + const parts: ChatMessagePart[] = [] + + const reasoning = + message.reasoning || + message.reasoning_content || + (typeof message.reasoning_details === 'string' ? message.reasoning_details : '') + + if (reasoning && message.role === 'assistant') { + parts.push(reasoningPart(reasoning)) + } + + if (displayContent) { + parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent)) + } + + if (message.role === 'assistant' && Array.isArray(message.tool_calls)) { + parts.push(...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex))) + } + + if (!parts.length) { + if (message.role !== 'assistant') { + flushPendingTools(index) + activeAssistantIndex = null + } + + return + } + + const isToolOnlyAssistant = + message.role === 'assistant' && parts.length > 0 && parts.every(part => part.type === 'tool-call') + + if (isToolOnlyAssistant) { + pendingToolParts = [...pendingToolParts, ...parts] + pendingToolTimestamp ??= message.timestamp + + return + } + + if (message.role === 'assistant') { + if (pendingToolParts.length) { + if (!appendPartsToActiveAssistant(pendingToolParts, message.timestamp ?? pendingToolTimestamp)) { + parts.unshift(...pendingToolParts) + } + + clearPendingTools() + } + + const activeAssistant = + activeAssistantIndex !== null && result[activeAssistantIndex]?.role === 'assistant' + ? result[activeAssistantIndex] + : null + + const currentHasToolCall = parts.some(part => part.type === 'tool-call') + const activeHasToolCall = Boolean(activeAssistant?.parts.some(part => part.type === 'tool-call')) + + if (activeAssistant && (currentHasToolCall || activeHasToolCall)) { + activeAssistant.parts = [...activeAssistant.parts, ...parts] + activeAssistant.timestamp = message.timestamp ?? activeAssistant.timestamp + + return + } + } else { + flushPendingTools(index) + } + + result.push({ + id: `${message.timestamp || Date.now()}-${index}-${message.role}`, + role: message.role, + parts, + timestamp: message.timestamp + }) + + activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null + }) + flushPendingTools(messages.length) + + return withUniqueToolCallIds( + result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text')) + ) +} + +export function preserveLocalAssistantErrors( + nextMessages: ChatMessage[], + currentMessages: ChatMessage[] +): ChatMessage[] { + const localById = new Map(currentMessages.map(message => [message.id, message])) + + const mergedNextMessages = nextMessages.map(message => { + if (message.role !== 'assistant' || message.error || message.hidden) { + return message + } + + const local = localById.get(message.id) + + if (!local || local.role !== 'assistant' || !local.error || local.hidden) { + return message + } + + return { + ...message, + error: local.error, + pending: false + } + }) + + const existingIds = new Set(mergedNextMessages.map(message => message.id)) + const preserveIds = new Set<string>() + const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() + const tailUserInNext = [...mergedNextMessages].reverse().find(message => message.role === 'user' && !message.hidden) + const tailUserText = tailUserInNext ? normalize(chatMessageText(tailUserInNext)) : '' + const tailUserRefs = tailUserInNext ? (tailUserInNext.attachmentRefs ?? []).join('\n') : '' + + const matchesTailUserInNext = (candidate: ChatMessage) => + Boolean(tailUserInNext) && + normalize(chatMessageText(candidate)) === tailUserText && + (candidate.attachmentRefs ?? []).join('\n') === tailUserRefs + + for (let index = 0; index < currentMessages.length; index += 1) { + const message = currentMessages[index] + + if (message.role !== 'assistant' || !message.error || message.hidden || existingIds.has(message.id)) { + continue + } + + preserveIds.add(message.id) + + for (let probe = index - 1; probe >= 0; probe -= 1) { + const candidate = currentMessages[probe] + + if (candidate.hidden) { + continue + } + + if (candidate.role === 'user' && !existingIds.has(candidate.id) && !matchesTailUserInNext(candidate)) { + preserveIds.add(candidate.id) + } + + break + } + } + + if (preserveIds.size === 0) { + return mergedNextMessages + } + + const preserved = currentMessages + .filter(message => preserveIds.has(message.id)) + .map(message => ({ ...message, pending: false })) + + return [...mergedNextMessages, ...preserved] +} + +export function branchGroupForUser(userMessage: ChatMessage): string { + return `branch:${userMessage.id}` +} diff --git a/apps/desktop/src/lib/chat-runtime.test.ts b/apps/desktop/src/lib/chat-runtime.test.ts new file mode 100644 index 000000000..c06ea6f32 --- /dev/null +++ b/apps/desktop/src/lib/chat-runtime.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { coerceThinkingText } from './chat-runtime' + +describe('coerceThinkingText', () => { + it('strips streaming status prefixes from thinking deltas', () => { + expect(coerceThinkingText("◉_◉ processing... checking the user's request")).toBe("checking the user's request") + expect(coerceThinkingText('(¬‿¬) analyzing... reading the file')).toBe('reading the file') + }) + + it('drops empty thinking rewrite placeholder text', () => { + expect( + coerceThinkingText( + "◉_◉ processing... I don't see any current rewritten thinking or next thinking to process. Could you provide the thinking content you'd like me to rewrite?" + ) + ).toBe('') + }) +}) diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts new file mode 100644 index 000000000..915869a4f --- /dev/null +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -0,0 +1,334 @@ +import type { ThreadMessage } from '@assistant-ui/react' + +import type { QuickModelOption } from '@/app/chat/composer/types' +import type { ClientSessionState, CommandDispatchResponse } from '@/app/types' +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages' +import type { ComposerAttachment } from '@/store/composer' +import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes' + +export const INTERRUPTED_MARKER = '\n\n_[interrupted]_' +export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/ +export const BUILTIN_PERSONALITIES = [ + 'helpful', + 'concise', + 'technical', + 'creative', + 'teacher', + 'kawaii', + 'catgirl', + 'pirate', + 'shakespeare', + 'surfer', + 'noir', + 'uwu', + 'philosopher', + 'hype' +] + +const THINKING_STATUS_PREFIX_RE = + /^\s*(?:(?:[^\s.]{1,16})\s+)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i + +const EMPTY_THINKING_PLACEHOLDER_RE = + /\b(?:current rewritten thinking|next thinking to process|provide the thinking content|don't see any .*thinking)\b/i + +export function createClientSessionState( + storedSessionId: string | null = null, + messages: ChatMessage[] = [] +): ClientSessionState { + return { + storedSessionId, + messages, + branch: '', + cwd: '', + busy: false, + awaitingResponse: false, + streamId: null, + sawAssistantPayload: false, + pendingBranchGroup: null, + interrupted: false + } +} + +export function sessionTitle(session: SessionInfo): string { + return session.title?.trim() || session.preview?.trim() || 'Untitled session' +} + +export function coerceGatewayText(value: unknown): string { + if (typeof value === 'string') { + return value + } + + if (value === null || value === undefined) { + return '' + } + + if (Array.isArray(value)) { + return value + .map(item => { + if (typeof item === 'string') { + return item + } + + if (item && typeof item === 'object') { + const row = item as Record<string, unknown> + + if (typeof row.text === 'string') { + return row.text + } + + if (typeof row.output_text === 'string') { + return row.output_text + } + } + + return '' + }) + .join('') + } + + if (typeof value === 'object') { + const row = value as Record<string, unknown> + + if (typeof row.text === 'string') { + return row.text + } + + if (typeof row.output_text === 'string') { + return row.output_text + } + + try { + return JSON.stringify(value) + } catch { + return '' + } + } + + return String(value) +} + +/** + * Normalize a reasoning/thinking text payload from the gateway. + * + * Only the leading status prefix (e.g. "Hermes is thinking...") and the + * obvious placeholder echoes are stripped. We deliberately do NOT trim + * the delta — reasoning streams as small chunks (often individual tokens + * with leading or trailing spaces), and trimming each chunk before + * concatenation collapses adjacent words together. Whitespace between + * tokens belongs to the data, not chrome. + */ +export function coerceThinkingText(value: unknown): string { + const raw = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '') + + return EMPTY_THINKING_PLACEHOLDER_RE.test(raw) ? '' : raw +} + +export function isImageGenerationTool(name?: string): boolean { + return name === 'image_generate' +} + +export function contextPath(path: string, cwd: string): string { + if (!cwd) { + return path + } + + const normalizedCwd = cwd.endsWith('/') ? cwd : `${cwd}/` + + return path.startsWith(normalizedCwd) ? path.slice(normalizedCwd.length) : path +} + +export function attachmentId(kind: ComposerAttachment['kind'], value: string): string { + return `${kind}:${value}` +} + +export function pathLabel(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path +} + +export function attachmentDisplayText(attachment: ComposerAttachment): string | null { + if (attachment.kind === 'terminal' && attachment.detail) { + return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\`` + } + + if (attachment.refText) { + return attachment.refText + } + + if (attachment.kind === 'image') { + const id = attachment.detail || attachment.path || attachment.label + + return id ? `@image:${formatRefValue(id)}` : null + } + + return null +} + +export function personalityNamesFromConfig(config: unknown): string[] { + const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {} + const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {} + const personalities = agent.personalities + + return personalities && typeof personalities === 'object' && !Array.isArray(personalities) + ? Object.keys(personalities as Record<string, unknown>) + : [] +} + +export function normalizePersonalityValue(value: string): string { + const trimmed = value.trim().toLowerCase() + + return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed +} + +export function parseSlashCommand(command: string) { + const match = command.replace(/^\/+/, '').match(/^(\S+)\s*(.*)$/) + + return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' } +} + +export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null { + if (!raw || typeof raw !== 'object') { + return null + } + + const row = raw as Record<string, unknown> + const str = (value: unknown) => (typeof value === 'string' ? value : undefined) + + switch (row.type) { + case 'exec': + + case 'plugin': + return { type: row.type, output: str(row.output) } + + case 'alias': + return typeof row.target === 'string' ? { type: 'alias', target: row.target } : null + + case 'skill': + return typeof row.name === 'string' ? { type: 'skill', name: row.name, message: str(row.message) } : null + + case 'send': + return typeof row.message === 'string' ? { type: 'send', message: row.message } : null + + default: + return null + } +} + +export function quickModelOptions( + data: ModelOptionsResponse | undefined, + currentProvider: string, + currentModel: string +): QuickModelOption[] { + const seen = new Set<string>() + const options: QuickModelOption[] = [] + + const providers = [...(data?.providers ?? [])].sort((a, b) => { + if (a.slug === currentProvider) { + return -1 + } + + if (b.slug === currentProvider) { + return 1 + } + + if (a.is_current) { + return -1 + } + + if (b.is_current) { + return 1 + } + + return 0 + }) + + const add = (provider: string, providerName: string, model: string) => { + const key = `${provider}:${model}` + + if (!model || seen.has(key)) { + return + } + + seen.add(key) + options.push({ provider, providerName, model }) + } + + if (currentProvider && currentModel) { + add(currentProvider, currentProvider, currentModel) + } + + for (const provider of providers) { + const models = [...(provider.models ?? [])].sort((a, b) => { + if (provider.slug === currentProvider && a === currentModel) { + return -1 + } + + if (provider.slug === currentProvider && b === currentModel) { + return 1 + } + + return 0 + }) + + for (const model of models) { + add(provider.slug, provider.name, model) + } + + if (options.length >= 8) { + break + } + } + + return options.slice(0, 8) +} + +export function toRuntimeMessage(message: ChatMessage): ThreadMessage { + const role = + message.role === 'user' || message.role === 'assistant' || message.role === 'system' ? message.role : 'assistant' + + const createdAt = message.timestamp + ? new Date(message.timestamp * 1000) + : new Date(Number(message.id.match(/\d+/)?.[0]) || Date.now()) + + if (role === 'user') { + return { + id: message.id, + role, + content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'), + attachments: [], + createdAt, + metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } } + } as ThreadMessage + } + + if (role === 'system') { + const text = chatMessageText(message) + + return { + id: message.id, + role, + content: [textPart(text)], + createdAt, + metadata: { custom: {} } + } as ThreadMessage + } + + return { + id: message.id, + role, + content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'], + createdAt, + status: message.error + ? { type: 'incomplete', reason: 'error', error: message.error } + : message.pending + ? { type: 'running' } + : { type: 'complete', reason: 'stop' }, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} diff --git a/apps/desktop/src/lib/clipboard.ts b/apps/desktop/src/lib/clipboard.ts new file mode 100644 index 000000000..ad5117ebc --- /dev/null +++ b/apps/desktop/src/lib/clipboard.ts @@ -0,0 +1,28 @@ +// Routes `navigator.clipboard.writeText` through Electron IPC, since the +// renderer's clipboard API throws "Write permission denied" whenever the +// document loses focus (e.g. clicking a portaled Radix dropdown). The IPC +// path runs in the main process and is unconditional. + +export function installClipboardShim() { + const ipc = window.hermesDesktop?.writeClipboard + + if (!ipc || !navigator.clipboard) { + return + } + + const native = navigator.clipboard.writeText?.bind(navigator.clipboard) + + const writeText = async (text: string) => { + try { + await ipc(text) + } catch { + await native?.(text) + } + } + + try { + Object.defineProperty(navigator.clipboard, 'writeText', { configurable: true, value: writeText, writable: true }) + } catch { + // Browser refused override; primitives keep using the native API. + } +} diff --git a/apps/desktop/src/lib/commit-changelog.test.ts b/apps/desktop/src/lib/commit-changelog.test.ts new file mode 100644 index 000000000..22f3525c9 --- /dev/null +++ b/apps/desktop/src/lib/commit-changelog.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import { buildCommitChangelog, parseCommitHeader } from './commit-changelog' + +describe('parseCommitHeader', () => { + it('extracts type, scope, and subject from a conventional header', () => { + expect(parseCommitHeader('feat(desktop): NSIS prereq detection page')).toEqual({ + breaking: false, + scope: 'desktop', + subject: 'NSIS prereq detection page', + type: 'feat' + }) + }) + + it('flags breaking changes via the `!` marker', () => { + expect(parseCommitHeader('feat(api)!: change endpoint shape')).toMatchObject({ + breaking: true, + type: 'feat' + }) + }) + + it('treats non-conventional commits as untyped with the full header as subject', () => { + expect(parseCommitHeader('Update README')).toEqual({ + breaking: false, + scope: null, + subject: 'Update README', + type: null + }) + }) + + it('ignores body lines and trims whitespace', () => { + expect(parseCommitHeader(' fix: handle null input \n\nMore detail')).toMatchObject({ + subject: 'handle null input', + type: 'fix' + }) + }) + + it('returns empty subject for blank input', () => { + expect(parseCommitHeader('')).toEqual({ breaking: false, scope: null, subject: '', type: null }) + }) +}) + +describe('buildCommitChangelog', () => { + it('groups commits into user-friendly buckets and capitalizes subjects', () => { + const groups = buildCommitChangelog([ + { summary: 'feat(desktop): add NSIS prereq detection page' }, + { summary: 'fix(sidebar): jitter when dragging' }, + { summary: 'perf: shave 200ms off cold start' }, + { summary: 'refactor: extract sidebar row component' } + ]) + + expect(groups.map(g => g.id)).toEqual(['new', 'fixed', 'faster']) + expect(groups[0]).toMatchObject({ label: "What's new" }) + expect(groups[0].items[0]).toBe('Add NSIS prereq detection page') + expect(groups[1].items[0]).toBe('Jitter when dragging') + }) + + it('hides chore/ci/docs/test commits', () => { + const groups = buildCommitChangelog([ + { summary: 'chore: bump deps' }, + { summary: 'ci: tweak workflow' }, + { summary: 'docs: spelling fix' }, + { summary: 'feat: real new feature' } + ]) + + expect(groups).toHaveLength(1) + expect(groups[0].items).toEqual(['Real new feature']) + }) + + it('routes unparseable commits to the "Other improvements" bucket', () => { + const groups = buildCommitChangelog([{ summary: 'Update sidebar styling' }]) + + expect(groups[0].id).toBe('other') + expect(groups[0].items).toEqual(['Update sidebar styling']) + }) + + it('falls back to a neutral placeholder when every commit is filtered or empty', () => { + const groups = buildCommitChangelog([{ summary: 'chore: bump' }, { summary: 'ci: stuff' }]) + + expect(groups).toEqual([{ id: 'other', items: ['Improvements and fixes'], label: 'In this update' }]) + }) + + it('dedupes identical subjects and caps the items per group', () => { + const groups = buildCommitChangelog( + [ + { summary: 'fix: thing A' }, + { summary: 'fix: thing A' }, + { summary: 'fix: thing B' }, + { summary: 'fix: thing C' }, + { summary: 'fix: thing D' }, + { summary: 'fix: thing E' } + ], + { maxPerGroup: 3, maxTotal: 10 } + ) + + expect(groups[0].items).toEqual(['Thing A', 'Thing B', 'Thing C']) + }) + + it('caps total entries across buckets', () => { + const groups = buildCommitChangelog( + [ + { summary: 'feat: a' }, + { summary: 'feat: b' }, + { summary: 'fix: c' }, + { summary: 'fix: d' }, + { summary: 'perf: e' } + ], + { maxTotal: 3 } + ) + + const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0) + expect(totalItems).toBe(3) + }) +}) diff --git a/apps/desktop/src/lib/commit-changelog.ts b/apps/desktop/src/lib/commit-changelog.ts new file mode 100644 index 000000000..5cd91c404 --- /dev/null +++ b/apps/desktop/src/lib/commit-changelog.ts @@ -0,0 +1,177 @@ +/** + * Tiny user-facing changelog builder. Takes a list of raw commit summaries, + * parses the Conventional Commits 1.0 header (`type(scope)!: subject`), + * filters internal noise (chore/ci/docs/...), and groups the rest into + * friendly buckets for end users (What's new, Fixed, Faster, Improved). + * + * Inlined (rather than depending on `conventional-commits-parser`) because + * that package's index re-exports a Node `stream` helper which won't load + * in the sandboxed Electron renderer, and its actual parse logic for the + * header is a small regex. + */ + +export type CommitGroupId = 'new' | 'fixed' | 'faster' | 'improved' | 'other' + +export interface CommitGroup { + id: CommitGroupId + label: string + items: string[] +} + +export interface ParsedCommit { + type: null | string + scope: null | string + breaking: boolean + subject: string +} + +export interface CommitChangelogInput { + summary?: string +} + +interface BuildOptions { + maxGroups?: number + maxPerGroup?: number + maxTotal?: number +} + +const GROUP_META: Record<CommitGroupId, { label: string; order: number }> = { + new: { label: "What's new", order: 0 }, + fixed: { label: 'Fixed', order: 1 }, + faster: { label: 'Faster', order: 2 }, + improved: { label: 'Improved', order: 3 }, + other: { label: 'Other improvements', order: 4 } +} + +const TYPE_TO_GROUP: Record<string, CommitGroupId> = { + feat: 'new', + feature: 'new', + fix: 'fixed', + bugfix: 'fixed', + hotfix: 'fixed', + revert: 'fixed', + perf: 'faster', + performance: 'faster', + refactor: 'improved', + a11y: 'improved', + ui: 'improved', + ux: 'improved' +} + +const HIDDEN_TYPES = new Set([ + 'build', + 'chore', + 'ci', + 'dep', + 'deps', + 'doc', + 'docs', + 'lint', + 'release', + 'style', + 'test', + 'tests', + 'wip' +]) + +const FALLBACK_GROUP: CommitGroup = { id: 'other', items: ['Improvements and fixes'], label: 'In this update' } + +const CONVENTIONAL_HEADER = /^(?<type>[a-zA-Z][a-zA-Z0-9_-]*)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<subject>.+)$/ + +/** Parse a single commit header line per Conventional Commits 1.0. */ +export function parseCommitHeader(raw: string): ParsedCommit { + const header = (raw ?? '').split(/\r?\n/, 1)[0].trim() + + if (!header) { + return { breaking: false, scope: null, subject: '', type: null } + } + + const match = CONVENTIONAL_HEADER.exec(header) + + if (!match?.groups) { + return { breaking: false, scope: null, subject: header, type: null } + } + + return { + breaking: Boolean(match.groups.bang), + scope: match.groups.scope ?? null, + subject: match.groups.subject.trim(), + type: match.groups.type.toLowerCase() + } +} + +function tidySubject(subject: string): string { + const cleaned = subject + .replace(/\s+/g, ' ') + .replace(/[.;,\s]+$/, '') + .trim() + + if (!cleaned) { + return cleaned + } + + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1) +} + +/** + * Build a small grouped changelog from a list of raw commits. + * Always returns at least one group; falls back to a neutral placeholder + * when every commit was filtered or unparseable. + */ +export function buildCommitChangelog( + commits: readonly CommitChangelogInput[] | undefined, + options: BuildOptions = {} +): CommitGroup[] { + const { maxGroups = 3, maxPerGroup = 4, maxTotal = 6 } = options + const groups = new Map<CommitGroupId, string[]>() + const seen = new Set<string>() + let total = 0 + + for (const commit of commits ?? []) { + if (total >= maxTotal) { + break + } + + const parsed = parseCommitHeader(commit.summary ?? '') + + if (parsed.type && HIDDEN_TYPES.has(parsed.type)) { + continue + } + + const groupId: CommitGroupId = parsed.type ? (TYPE_TO_GROUP[parsed.type] ?? 'other') : 'other' + const subject = tidySubject(parsed.subject) + + if (!subject) { + continue + } + + const dedupeKey = subject.toLowerCase() + + if (seen.has(dedupeKey)) { + continue + } + + const bucket = groups.get(groupId) ?? [] + + if (bucket.length >= maxPerGroup) { + continue + } + + bucket.push(subject) + groups.set(groupId, bucket) + seen.add(dedupeKey) + total += 1 + } + + const result = Array.from(groups.entries()) + .map(([id, items]) => ({ id, items, label: GROUP_META[id].label, order: GROUP_META[id].order })) + .sort((a, b) => a.order - b.order) + .slice(0, maxGroups) + .map(({ id, items, label }): CommitGroup => ({ id, items, label })) + + if (result.length === 0) { + return [FALLBACK_GROUP] + } + + return result +} diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts new file mode 100644 index 000000000..8b3666e22 --- /dev/null +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' + +import { + desktopSkinSlashCompletions, + desktopSlashDescription, + desktopSlashUnavailableMessage, + filterDesktopCommandsCatalog, + isDesktopSlashCommand, + isDesktopSlashSuggestion +} from './desktop-slash-commands' + +describe('desktop slash command curation', () => { + it('keeps core desktop chat commands in suggestions', () => { + expect(isDesktopSlashSuggestion('/new')).toBe(true) + expect(isDesktopSlashSuggestion('/branch')).toBe(true) + expect(isDesktopSlashSuggestion('/skin')).toBe(true) + expect(isDesktopSlashSuggestion('/usage')).toBe(true) + }) + + it('lets explicitly typed extension commands run without suggesting them', () => { + expect(isDesktopSlashSuggestion('/my-skill')).toBe(false) + expect(isDesktopSlashCommand('/my-skill')).toBe(true) + }) + + it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => { + expect(isDesktopSlashSuggestion('/clear')).toBe(false) + expect(isDesktopSlashSuggestion('/compact')).toBe(false) + expect(isDesktopSlashSuggestion('/redraw')).toBe(false) + expect(isDesktopSlashSuggestion('/approve')).toBe(false) + expect(isDesktopSlashSuggestion('/model')).toBe(false) + expect(isDesktopSlashSuggestion('/skills')).toBe(false) + expect(isDesktopSlashSuggestion('/voice')).toBe(false) + expect(isDesktopSlashSuggestion('/curator')).toBe(false) + }) + + it('allows aliases to execute without cluttering the popover', () => { + expect(isDesktopSlashSuggestion('/reset')).toBe(false) + expect(isDesktopSlashCommand('/reset')).toBe(true) + }) + + it('filters command catalogs down to core desktop commands', () => { + const filtered = filterDesktopCommandsCatalog({ + categories: [ + { + name: 'Session', + pairs: [ + ['/new', 'Start a new session'], + ['/clear', 'Clear terminal screen'] + ] + }, + { + name: 'User commands', + pairs: [['/ship-it', 'Run release checklist']] + } + ], + pairs: [ + ['/new', 'Start a new session'], + ['/model', 'Switch model'], + ['/ship-it', 'Run release checklist'] + ], + skill_count: 2 + }) + + expect(filtered.categories).toEqual([{ name: 'Session', pairs: [['/new', 'Start a new desktop chat']] }]) + expect(filtered.pairs).toEqual([['/new', 'Start a new desktop chat']]) + expect(filtered.skill_count).toBe(2) + }) + + it('uses desktop-specific labels for commands with different UI behavior', () => { + expect(desktopSlashDescription('/branch', 'Branch the current session')).toBe( + 'Branch the latest message into a new chat' + ) + expect(desktopSlashDescription('/skin', 'Show or change the display skin/theme')).toBe( + 'Switch desktop theme or cycle to the next one' + ) + }) + + it('builds /skin completions from desktop themes', () => { + const completions = desktopSkinSlashCompletions( + [ + { name: 'mono', label: 'Mono', description: 'Clean grayscale' }, + { name: 'midnight', label: 'Midnight', description: 'Deep blue' }, + { name: 'slate', label: 'Slate', description: 'Cool slate blue' } + ], + 'mono', + 'm' + ) + + expect(completions).toEqual([ + { + text: '/skin mono', + display: '/skin mono', + meta: 'Mono (current) - Clean grayscale' + }, + { + text: '/skin midnight', + display: '/skin midnight', + meta: 'Midnight - Deep blue' + } + ]) + }) + + it('explains known commands that desktop owns elsewhere', () => { + expect(desktopSlashUnavailableMessage('/model sonnet')).toContain('model picker') + expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar') + expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface') + }) +}) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts new file mode 100644 index 000000000..db3a4ec3e --- /dev/null +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -0,0 +1,248 @@ +export interface CommandsCatalogSection { + name: string + pairs: [string, string][] +} + +export interface CommandsCatalogLike { + categories?: CommandsCatalogSection[] + pairs?: [string, string][] + skill_count?: number + warning?: string +} + +export interface DesktopSlashCompletion { + display: string + meta: string + text: string +} + +export interface DesktopThemeCommandOption { + description: string + label: string + name: string +} + +const DESKTOP_COMMAND_META = [ + ['/agents', 'Show active desktop sessions and running tasks'], + ['/background', 'Run a prompt in the background'], + ['/branch', 'Branch the latest message into a new chat'], + ['/compress', 'Compress this conversation context'], + ['/debug', 'Create a debug report'], + ['/goal', 'Manage the standing goal for this session'], + ['/help', 'Show desktop slash commands'], + ['/new', 'Start a new desktop chat'], + ['/queue', 'Queue a prompt for the next turn'], + ['/resume', 'Resume a saved session'], + ['/retry', 'Retry the last user message'], + ['/rollback', 'List or restore filesystem checkpoints'], + ['/skin', 'Switch desktop theme or cycle to the next one'], + ['/status', 'Show current session status'], + ['/steer', 'Steer the current run after the next tool call'], + ['/stop', 'Stop running background processes'], + ['/title', 'Rename the current session'], + ['/undo', 'Remove the last user/assistant exchange'], + ['/usage', 'Show token usage for this session'] +] as const + +const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command)) + +const DESKTOP_ALIASES = new Map([ + ['/bg', '/background'], + ['/btw', '/background'], + ['/fork', '/branch'], + ['/q', '/queue'], + ['/reload_mcp', '/reload-mcp'], + ['/reload_skills', '/reload-skills'], + ['/reset', '/new'], + ['/tasks', '/agents'] +]) + +const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META) + +const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider']) + +const TERMINAL_ONLY_COMMANDS = new Set([ + '/browser', + '/busy', + '/clear', + '/commands', + '/compact', + '/config', + '/copy', + '/cron', + '/details', + '/exit', + '/footer', + '/gateway', + '/gquota', + '/history', + '/image', + '/indicator', + '/logs', + '/mouse', + '/paste', + '/platforms', + '/plugins', + '/quit', + '/redraw', + '/reload', + '/restart', + '/save', + '/sb', + '/set-home', + '/sethome', + '/snap', + '/snapshot', + '/statusbar', + '/toolsets', + '/tools', + '/update', + '/verbose' +]) + +const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny']) + +const SETTINGS_OWNED_COMMANDS = new Set(['/skills']) + +const ADVANCED_COMMANDS = new Set([ + '/curator', + '/fast', + '/insights', + '/kanban', + '/personality', + '/profile', + '/reasoning', + '/reload-mcp', + '/reload-skills', + '/voice', + '/yolo' +]) + +const BLOCKED_COMMANDS = new Set([ + ...PICKER_OWNED_COMMANDS, + ...TERMINAL_ONLY_COMMANDS, + ...MESSAGING_ONLY_COMMANDS, + ...SETTINGS_OWNED_COMMANDS, + ...ADVANCED_COMMANDS +]) + +function normalizeCommand(command: string): string { + const trimmed = command.trim() + const base = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`).split(/\s+/, 1)[0]?.toLowerCase() || '' + + return base +} + +export function canonicalDesktopSlashCommand(command: string): string { + const normalized = normalizeCommand(command) + + return DESKTOP_ALIASES.get(normalized) || normalized +} + +export function isDesktopSlashCommand(command: string): boolean { + const normalized = normalizeCommand(command) + const canonical = canonicalDesktopSlashCommand(normalized) + + if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) { + return false + } + + return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized) +} + +export function isDesktopSlashSuggestion(command: string): boolean { + const normalized = normalizeCommand(command) + const canonical = canonicalDesktopSlashCommand(normalized) + + return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized) +} + +export function desktopSlashUnavailableMessage(command: string): string | null { + const normalized = normalizeCommand(command) + const canonical = canonicalDesktopSlashCommand(normalized) + + if (PICKER_OWNED_COMMANDS.has(canonical)) { + return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.` + } + + if (SETTINGS_OWNED_COMMANDS.has(canonical)) { + return `/${canonical.slice(1)} is managed from the desktop sidebar.` + } + + if (MESSAGING_ONLY_COMMANDS.has(canonical)) { + return `/${canonical.slice(1)} is only used from messaging platforms.` + } + + if (ADVANCED_COMMANDS.has(canonical)) { + return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.` + } + + if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) { + return `/${canonical.slice(1)} is only available in the terminal interface.` + } + + return null +} + +export function desktopSlashDescription(command: string, fallback = ''): string { + const canonical = canonicalDesktopSlashCommand(command) + + return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback +} + +export function desktopSkinSlashCompletions( + themes: DesktopThemeCommandOption[], + activeThemeName: string, + argPrefix: string +): DesktopSlashCompletion[] { + const prefix = argPrefix.trim().toLowerCase() + + const commands: DesktopSlashCompletion[] = [ + { + text: '/skin list', + display: '/skin list', + meta: 'Show available desktop themes' + }, + { + text: '/skin next', + display: '/skin next', + meta: 'Cycle to the next desktop theme' + }, + ...themes.map(theme => ({ + text: `/skin ${theme.name}`, + display: `/skin ${theme.name}`, + meta: `${theme.label}${theme.name === activeThemeName ? ' (current)' : ''} - ${theme.description}` + })) + ] + + if (!prefix) { + return commands + } + + return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix)) +} + +export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike { + const categories = catalog.categories + ?.map(section => ({ + ...section, + pairs: section.pairs + .filter(([command]) => isDesktopSlashSuggestion(command)) + .map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string]) + })) + .filter(section => section.pairs.length > 0) + + const pairs = catalog.pairs + ?.filter(([command]) => isDesktopSlashSuggestion(command)) + .map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string]) + + return { + ...catalog, + ...(categories ? { categories } : {}), + ...(pairs ? { pairs } : {}) + } +} + +function isKnownHermesSlashCommand(command: string): boolean { + return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command) +} diff --git a/apps/desktop/src/lib/embedded-images.test.ts b/apps/desktop/src/lib/embedded-images.test.ts new file mode 100644 index 000000000..5e6df1c50 --- /dev/null +++ b/apps/desktop/src/lib/embedded-images.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { extractEmbeddedImages } from './embedded-images' + +const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120) + +describe('extractEmbeddedImages', () => { + it('returns text untouched when no data URL is present', () => { + expect(extractEmbeddedImages('describe this')).toEqual({ cleanedText: 'describe this', images: [] }) + }) + + it('lifts a bare data:image URL out of prose', () => { + const result = extractEmbeddedImages(`describe this ${SAMPLE_PNG_DATA_URL}`) + + expect(result.cleanedText).toBe('describe this') + expect(result.images).toEqual([SAMPLE_PNG_DATA_URL]) + }) + + it('lifts a JSON-wrapped image_url envelope out of prose', () => { + const result = extractEmbeddedImages( + `describe this{"type":"image_url","image_url":{"url":"${SAMPLE_PNG_DATA_URL}"}}` + ) + + expect(result.cleanedText).toBe('describe this') + expect(result.images).toEqual([SAMPLE_PNG_DATA_URL]) + }) + + it('extracts multiple embedded images', () => { + const second = 'data:image/jpeg;base64,' + 'B'.repeat(96) + const result = extractEmbeddedImages(`first ${SAMPLE_PNG_DATA_URL} mid ${second} tail`) + + expect(result.cleanedText).toBe('first mid tail') + expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second]) + }) +}) diff --git a/apps/desktop/src/lib/embedded-images.ts b/apps/desktop/src/lib/embedded-images.ts new file mode 100644 index 000000000..3d9901513 --- /dev/null +++ b/apps/desktop/src/lib/embedded-images.ts @@ -0,0 +1,60 @@ +const EMBEDDED_IMAGE_RE = + /(\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*")?(data:image\/[\w.+-]+;base64,[A-Za-z0-9+/=]{64,})("\s*\}\s*\})?/g + +const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i + +export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i + +export interface EmbeddedImageExtraction { + cleanedText: string + images: string[] +} + +export function dataUrlToBlob(dataUrl: string): Blob | null { + const match = DATA_URL_RE.exec(dataUrl.trim()) + + if (!match) { + return null + } + + try { + const bytes = atob(match[2]) + const buffer = new Uint8Array(bytes.length) + + for (let i = 0; i < bytes.length; i += 1) { + buffer[i] = bytes.charCodeAt(i) + } + + return new Blob([buffer], { type: match[1] }) + } catch { + return null + } +} + +export function extractEmbeddedImages(text: string): EmbeddedImageExtraction { + if (!text || !text.includes('data:image/')) { + return { cleanedText: text, images: [] } + } + + const images: string[] = [] + + const cleanedText = text + .replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => { + images.push(dataUrl) + + return '' + }) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + + return { cleanedText, images } +} + +export function embeddedImageUrls(text: string): string[] { + return extractEmbeddedImages(text).images +} + +export function textWithoutEmbeddedImages(text: string): string { + return extractEmbeddedImages(text).cleanedText +} diff --git a/apps/desktop/src/lib/external-link.test.tsx b/apps/desktop/src/lib/external-link.test.tsx new file mode 100644 index 000000000..4e5295765 --- /dev/null +++ b/apps/desktop/src/lib/external-link.test.tsx @@ -0,0 +1,168 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + __resetLinkTitleCache, + ExternalLink, + fetchLinkTitle, + hostPathLabel, + isTitleFetchable, + LinkifiedText, + PrettyLink, + urlSlugTitleLabel +} from './external-link' + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const initialHermesDesktop = desktopWindow.hermesDesktop + +function installDesktopBridge(partial: Partial<Window['hermesDesktop']> = {}) { + desktopWindow.hermesDesktop = { + fetchLinkTitle: vi.fn().mockResolvedValue(''), + openExternal: vi.fn().mockResolvedValue(undefined), + ...partial + } as unknown as Window['hermesDesktop'] +} + +afterEach(() => { + __resetLinkTitleCache() + vi.restoreAllMocks() + cleanup() + + if (initialHermesDesktop) { + desktopWindow.hermesDesktop = initialHermesDesktop + } else { + delete desktopWindow.hermesDesktop + } +}) + +describe('external link helpers', () => { + it('formats URL fallbacks as host + path', () => { + expect( + hostPathLabel( + 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + ) + ).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894') + }) + + it('derives readable title fallbacks from URL slugs', () => { + expect( + urlSlugTitleLabel( + 'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/' + ) + ).toBe('From Fajardo Icacos Island Full Day Catamaran Trip') + }) + + it('filters out local/non-http targets for title fetches', () => { + expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true) + expect(isTitleFetchable('http://localhost:5174')).toBe(false) + expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false) + expect(isTitleFetchable('mailto:hello@example.com')).toBe(false) + }) + + it('deduplicates in-flight title fetches and caches results', async () => { + const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] }) + + const url = + 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details' + + const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)]) + + expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(bridge).toHaveBeenCalledTimes(1) + + const third = await fetchLinkTitle(url) + + expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(bridge).toHaveBeenCalledTimes(1) + }) + + it('shares cache across protocol/www URL variants', async () => { + const bridge = vi.fn().mockResolvedValue('Shared Canonical Title') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] }) + + const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/' + const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/' + + const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)]) + + expect(a).toBe('Shared Canonical Title') + expect(b).toBe('Shared Canonical Title') + expect(bridge).toHaveBeenCalledTimes(1) + }) + + it('opens links via the desktop bridge', () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] }) + + render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>) + + fireEvent.click(screen.getByRole('link', { name: 'Example link' })) + expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource') + }) + + it('shows a trailing external-link icon', () => { + installDesktopBridge() + + render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>) + + const link = screen.getByRole('link', { name: 'Example link' }) + expect(link.querySelector('svg')).toBeTruthy() + }) + + it('renders pretty links with fetched titles and no host suffix', async () => { + const bridge = vi.fn().mockResolvedValue('From Fajardo: Full-Day Culebra Islands Catamaran Tour') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] }) + + const url = + 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + + render(<LinkifiedText text={`Read ${url}`} />) + + const link = screen.getByTitle(url) + expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour') + + await waitFor(() => { + expect(link.textContent).toContain('From Fajardo: Full-Day Culebra Islands Catamaran Tour') + }) + expect(link.textContent).not.toContain('getyourguide.com') + }) + + it('shows host/path fallback when title is unavailable', () => { + installDesktopBridge() + const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque' + + render(<PrettyLink href={url} />) + + const link = screen.getByTitle(url) + + expect(link.textContent).toBe('Puerto Rico El Yunque') + }) + + it('ignores error-like fetched titles and falls back to slug label', async () => { + const bridge = vi.fn().mockResolvedValue('GetYourGuide – Error') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] }) + + const url = + 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + + render(<PrettyLink href={url} />) + + const link = screen.getByTitle(url) + await waitFor(() => { + expect(link.textContent).toBe('From Fajardo Full Day Cordillera Islands Catamaran Tour') + }) + }) + + it('normalizes scheme-less links before opening', () => { + installDesktopBridge() + + render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />) + + const link = screen.getByRole('link') + expect(link.getAttribute('href')).toBe( + 'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure' + ) + }) +}) diff --git a/apps/desktop/src/lib/external-link.tsx b/apps/desktop/src/lib/external-link.tsx new file mode 100644 index 000000000..05f1ec02f --- /dev/null +++ b/apps/desktop/src/lib/external-link.tsx @@ -0,0 +1,303 @@ +import type { ComponentProps, ReactNode } from 'react' +import { useEffect, useMemo, useState } from 'react' + +import { ArrowUpRight } from '@/lib/icons' + +import { cn } from './utils' + +const titleCache = new Map<string, string>() +const titleInflight = new Map<string, Promise<string>>() +const titleSubs = new Map<string, Set<(value: string) => void>>() + +const URL_RE = + /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi + +const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i +const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i +const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i + +const ERROR_TITLE_RE = + /\b(?:access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i + +export function normalizeExternalUrl(value: string): string { + const trimmed = value.trim() + + if (!trimmed || /^https?:\/\//i.test(trimmed)) { + return trimmed + } + + return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed +} + +function parseUrl(value: string): null | URL { + try { + return new URL(normalizeExternalUrl(value)) + } catch { + return null + } +} + +function titleCacheKey(value: string): string { + const url = parseUrl(value) + + if (!url) { + return normalizeExternalUrl(value) + } + + const host = url.hostname.replace(/^www\./i, '').toLowerCase() + const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' + + return `${host}${pathname}${url.search || ''}` +} + +export function shortHostLabel(value: string): string { + return parseUrl(value)?.hostname.replace(/^www\./, '') ?? value +} + +export function hostPathLabel(value: string): string { + const url = parseUrl(value) + + if (!url) { + return value + } + + const host = url.hostname.replace(/^www\./, '') + const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '' + + return `${host}${path}` +} + +function cleanSlug(segment: string): string { + try { + return decodeURIComponent(segment) + .replace(/\.a\d+\..*$/i, '') + .replace(/\.(?:html?|php|aspx?)$/i, '') + .replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + } catch { + return '' + } +} + +export function urlSlugTitleLabel(value: string): string { + const url = parseUrl(value) + + for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) { + const cleaned = cleanSlug(segment) + + if (!cleaned || !/[a-z]/i.test(cleaned)) { + continue + } + + if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) { + continue + } + + const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase()) + + if (titled.length >= 4) { + return titled + } + } + + return hostPathLabel(value) +} + +export function isTitleFetchable(value: string): boolean { + if (!value || SKIP_PROTO_RE.test(value)) { + return false + } + + const url = parseUrl(value) + + return Boolean(url && /^https?:$/.test(url.protocol) && !LOCAL_HOST_RE.test(url.host)) +} + +export function fetchLinkTitle(url: string): Promise<string> { + const normalizedUrl = normalizeExternalUrl(url) + const key = titleCacheKey(normalizedUrl) + + if (!isTitleFetchable(normalizedUrl)) { + return Promise.resolve('') + } + + if (titleCache.has(key)) { + return Promise.resolve(titleCache.get(key) ?? '') + } + + const pending = titleInflight.get(key) + + if (pending) { + return pending + } + + const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.fetchLinkTitle + + if (!bridge) { + titleCache.set(key, '') + + return Promise.resolve('') + } + + const promise = bridge(normalizedUrl) + .then(value => (value || '').replace(/\s+/g, ' ').trim()) + .then(clean => (clean && !ERROR_TITLE_RE.test(clean) ? clean : '')) + .catch(() => '') + .then(safe => { + titleCache.set(key, safe) + titleInflight.delete(key) + titleSubs.get(key)?.forEach(sub => sub(safe)) + + return safe + }) + + titleInflight.set(key, promise) + + return promise +} + +export function useLinkTitle(url?: null | string): string { + const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url]) + const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl]) + const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : '')) + + useEffect(() => { + setTitle(key ? (titleCache.get(key) ?? '') : '') + + if (!key || !isTitleFetchable(normalizedUrl)) { + return + } + + const subs = titleSubs.get(key) ?? new Set<(value: string) => void>() + + subs.add(setTitle) + titleSubs.set(key, subs) + void fetchLinkTitle(normalizedUrl) + + return () => { + subs.delete(setTitle) + + if (!subs.size) { + titleSubs.delete(key) + } + } + }, [key, normalizedUrl]) + + return title +} + +export function openExternalLink(href: string): void { + if (href) { + void window.hermesDesktop?.openExternal?.(href) + } +} + +interface ExternalLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> { + href: string + children?: ReactNode + showExternalIcon?: boolean +} + +export function ExternalLinkIcon({ className }: { className?: string }) { + return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} /> +} + +export function ExternalLink({ + children, + className, + href, + onClick, + showExternalIcon = true, + ...rest +}: ExternalLinkProps) { + const target = normalizeExternalUrl(href) + + return ( + <a + className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current/20', className)} + href={target} + onClick={event => { + event.stopPropagation() + onClick?.(event) + + if (event.defaultPrevented) { + return + } + + event.preventDefault() + openExternalLink(target) + }} + rel="noopener noreferrer" + target="_blank" + {...rest} + > + {children ?? urlSlugTitleLabel(target)} + {showExternalIcon && <ExternalLinkIcon />} + </a> + ) +} + +interface PrettyLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> { + href: string + label?: string + fallbackLabel?: string +} + +export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: PrettyLinkProps) { + const target = useMemo(() => normalizeExternalUrl(href), [href]) + const fetched = useLinkTitle(label ? null : target) + const display = fetched || label?.trim() || fallbackLabel?.trim() || urlSlugTitleLabel(target) + + return ( + <ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}> + <span className="font-medium">{display}</span> + </ExternalLink> + ) +} + +interface LinkifiedTextProps { + className?: string + text: string + pretty?: boolean +} + +export function LinkifiedText({ className, pretty = true, text }: LinkifiedTextProps) { + const nodes: ReactNode[] = [] + let cursor = 0 + + for (const match of text.matchAll(URL_RE)) { + const raw = match[0] + const url = normalizeExternalUrl(raw) + const index = match.index ?? 0 + + if (index > cursor) { + nodes.push(text.slice(cursor, index)) + } + + nodes.push( + pretty ? ( + <PrettyLink href={url} key={`${url}-${index}`} /> + ) : ( + <ExternalLink href={url} key={`${url}-${index}`}> + {raw} + </ExternalLink> + ) + ) + + cursor = index + raw.length + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)) + } + + return <span className={className}>{nodes.length ? nodes : text}</span> +} + +export function __resetLinkTitleCache(): void { + titleCache.clear() + titleInflight.clear() + titleSubs.clear() +} diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts new file mode 100644 index 000000000..fe6b1a0f7 --- /dev/null +++ b/apps/desktop/src/lib/gateway-events.ts @@ -0,0 +1,42 @@ +import type { StatusbarMenuItem } from '@/app/shell/statusbar-controls' + +const LOG_TAIL = 5 + +interface RpcEventLike { + payload?: unknown + type?: string +} + +function asRecord(payload: unknown): Record<string, unknown> { + return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {} +} + +export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean { + if (event.type !== 'tool.complete') { + return false + } + + const diff = asRecord(event.payload).inline_diff + + return typeof diff === 'string' && diff.trim().length > 0 +} + +export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] { + if (lines.length === 0) { + return [ + { + className: 'text-muted-foreground', + disabled: true, + id: 'gateway-log-empty', + label: 'No recent gateway log lines' + } + ] + } + + return lines.slice(-LOG_TAIL).map((line, index) => ({ + className: 'font-mono text-[0.68rem] text-muted-foreground', + disabled: true, + id: `gateway-log:${index}`, + label: line.trim().slice(0, 120) || '(blank log line)' + })) +} diff --git a/apps/desktop/src/lib/haptics.ts b/apps/desktop/src/lib/haptics.ts new file mode 100644 index 000000000..7b1a9c3d9 --- /dev/null +++ b/apps/desktop/src/lib/haptics.ts @@ -0,0 +1,112 @@ +import type { HapticInput, TriggerOptions } from 'web-haptics' + +import { $hapticsMuted } from '@/store/haptics' + +export type HapticIntent = + | 'cancel' + | 'close' + | 'crisp' + | 'error' + | 'open' + | 'selection' + | 'streamDone' + | 'streamStart' + | 'submit' + | 'success' + | 'tap' + | 'warning' + +interface HapticConfig { + options?: TriggerOptions + pattern: HapticInput +} + +const airyTap = [{ duration: 16, intensity: 0.52 }] + +const crispTap = [{ duration: 10, intensity: 0.92 }] + +const friendlySuccess = [ + { duration: 28, intensity: 0.5 }, + { delay: 42, duration: 30, intensity: 0.68 }, + { delay: 48, duration: 38, intensity: 0.86 } +] + +const softArrive = [ + { duration: 18, intensity: 0.42 }, + { delay: 36, duration: 22, intensity: 0.66 } +] + +const softLeave = [ + { duration: 22, intensity: 0.58 }, + { delay: 32, duration: 16, intensity: 0.34 } +] + +const HAPTIC_INTENTS: Record<HapticIntent, HapticConfig> = { + cancel: { + pattern: [ + { duration: 34, intensity: 0.72 }, + { delay: 54, duration: 26, intensity: 0.38 } + ] + }, + close: { pattern: softLeave }, + crisp: { pattern: crispTap }, + error: { + pattern: [ + { duration: 34, intensity: 0.82 }, + { delay: 42, duration: 34, intensity: 0.72 }, + { delay: 58, duration: 44, intensity: 0.86 } + ] + }, + open: { pattern: softArrive }, + selection: { pattern: airyTap }, + streamDone: { pattern: friendlySuccess }, + streamStart: { pattern: [{ duration: 10, intensity: 0.32 }] }, + submit: { + pattern: [ + { duration: 24, intensity: 0.58 }, + { delay: 48, duration: 36, intensity: 0.82 } + ] + }, + success: { pattern: friendlySuccess }, + tap: { + pattern: [ + { duration: 14, intensity: 0.58 }, + { delay: 30, duration: 12, intensity: 0.42 } + ] + }, + warning: { + pattern: [ + { duration: 34, intensity: 0.64 }, + { delay: 84, duration: 42, intensity: 0.5 } + ] + } +} + +export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => Promise<void> | undefined + +let registeredTrigger: HapticTrigger | null = null +let lastSelectionAt = 0 + +export function registerHapticTrigger(trigger: HapticTrigger | null) { + registeredTrigger = trigger +} + +export function triggerHaptic(intent: HapticIntent = 'selection') { + if ($hapticsMuted.get() || !registeredTrigger) { + return + } + + const now = performance.now() + + if (intent === 'selection') { + if (now - lastSelectionAt < 50) { + return + } + + lastSelectionAt = now + } + + const config = HAPTIC_INTENTS[intent] + + void registeredTrigger(config.pattern, config.options)?.catch(() => undefined) +} diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts new file mode 100644 index 000000000..54d71e770 --- /dev/null +++ b/apps/desktop/src/lib/icons.ts @@ -0,0 +1,193 @@ +import { + IconActivity as Activity, + IconAlertCircle as AlertCircle, + IconAlertTriangle as AlertTriangle, + IconArrowUp as ArrowUp, + IconArrowUpRight as ArrowUpRight, + IconAt as AtSign, + IconWaveSine as AudioLines, + IconChartBar as BarChart3, + IconBrain as Brain, + IconBug as Bug, + IconCheck as Check, + IconCircleCheck as CheckCircle2, + IconCheck as CheckIcon, + IconChevronDown as ChevronDown, + IconChevronDown as ChevronDownIcon, + IconChevronLeft as ChevronLeft, + IconChevronLeft as ChevronLeftIcon, + IconChevronRight as ChevronRight, + IconChevronRight as ChevronRightIcon, + IconCircle as CircleIcon, + IconClipboard as Clipboard, + IconClock as Clock, + IconCommand as Command, + IconCopy as Copy, + IconCopy as CopyIcon, + IconCpu as Cpu, + IconDownload as Download, + IconExternalLink as ExternalLink, + IconEye as Eye, + IconEyeOff as EyeOff, + IconPhoto as FileImage, + IconFileText as FileText, + IconFolderOpen as FolderOpen, + IconGitBranch as GitBranch, + IconGitBranch as GitBranchIcon, + IconGlobe as Globe, + IconHash as Hash, + IconHelpCircle as HelpCircle, + IconPhoto as ImageIcon, + IconInfoCircle as Info, + IconKey as KeyRound, + IconLayersIntersect2 as Layers3, + IconLink as Link, + IconLink as Link2, + IconLink as LinkIcon, + IconLoader2 as Loader2, + IconLoader2 as Loader2Icon, + IconLock as Lock, + IconMessageCircle as MessageCircle, + IconMessage2 as MessageSquareText, + IconMicrophone as Mic, + IconMicrophoneOff as MicOff, + IconDeviceDesktop as Monitor, + IconDeviceDesktopAnalytics as MonitorPlay, + IconMoon as Moon, + IconDots as MoreHorizontal, + IconDots as MoreHorizontalIcon, + IconDotsVertical as MoreVertical, + IconNotebook as NotebookTabs, + IconPackage as Package, + IconPalette as Palette, + IconLayoutBottombar as PanelBottom, + IconLayoutSidebar as PanelLeftIcon, + IconPlayerPause as Pause, + IconPencil as Pencil, + IconPencil as PencilIcon, + IconPencil as PencilLine, + IconPin as Pin, + IconPlayerPlay as Play, + IconPlus as Plus, + IconRefresh as RefreshCw, + IconRefresh as RefreshCwIcon, + IconDeviceFloppy as Save, + IconSearch as Search, + IconSearch as SearchIcon, + IconSend as Send, + IconSettings as Settings, + IconSettings2 as Settings2, + IconAdjustmentsHorizontal as SlidersHorizontal, + IconSparkles as Sparkles, + IconSquare as Square, + IconSun as Sun, + IconTerminal2 as Terminal, + IconTrash as Trash2, + IconUsers as Users, + IconVolume2 as Volume2, + IconVolume2 as Volume2Icon, + IconVolumeOff as VolumeX, + IconVolumeOff as VolumeXIcon, + IconTool as Wrench, + IconX as X, + IconX as XIcon, + IconBolt as Zap +} from '@tabler/icons-react' + +export { + Activity, + AlertCircle, + AlertTriangle, + ArrowUp, + ArrowUpRight, + AtSign, + AudioLines, + BarChart3, + Brain, + Bug, + Check, + CheckCircle2, + CheckIcon, + ChevronDown, + ChevronDownIcon, + ChevronLeft, + ChevronLeftIcon, + ChevronRight, + ChevronRightIcon, + CircleIcon, + Clipboard, + Clock, + Command, + Copy, + CopyIcon, + Cpu, + Download, + ExternalLink, + Eye, + EyeOff, + FileImage, + FileText, + FolderOpen, + GitBranch, + GitBranchIcon, + Globe, + Hash, + HelpCircle, + ImageIcon, + Info, + KeyRound, + Layers3, + Link, + Link2, + LinkIcon, + Loader2, + Loader2Icon, + Lock, + MessageCircle, + MessageSquareText, + Mic, + MicOff, + Monitor, + MonitorPlay, + Moon, + MoreHorizontal, + MoreHorizontalIcon, + MoreVertical, + NotebookTabs, + Package, + Palette, + PanelBottom, + PanelLeftIcon, + Pause, + Pencil, + PencilIcon, + PencilLine, + Pin, + Play, + Plus, + RefreshCw, + RefreshCwIcon, + Save, + Search, + SearchIcon, + Send, + Settings, + Settings2, + SlidersHorizontal, + Sparkles, + Square, + Sun, + Terminal, + Trash2, + Users, + Volume2, + Volume2Icon, + VolumeX, + VolumeXIcon, + Wrench, + X, + XIcon, + Zap +} + +export type { Icon as IconComponent } from '@tabler/icons-react' diff --git a/apps/desktop/src/lib/incremental-external-store-runtime.ts b/apps/desktop/src/lib/incremental-external-store-runtime.ts new file mode 100644 index 000000000..c05517509 --- /dev/null +++ b/apps/desktop/src/lib/incremental-external-store-runtime.ts @@ -0,0 +1,188 @@ +import { + AssistantRuntimeImpl, + BaseAssistantRuntimeCore, + ExternalStoreThreadListRuntimeCore, + ExternalStoreThreadRuntimeCore, + hasUpcomingMessage +} from '@assistant-ui/core/internal' +import { + type AssistantRuntime, + type ExternalStoreAdapter, + type ThreadMessage, + useRuntimeAdapters +} from '@assistant-ui/react' +import { useEffect, useMemo, useState } from 'react' + +const EMPTY_ARRAY = Object.freeze([]) + +const shallowEqual = (a: object, b: object): boolean => { + const aKeys = Object.keys(a) + + if (aKeys.length !== Object.keys(b).length) { + return false + } + + for (const key of aKeys) { + if (a[key as keyof typeof a] !== b[key as keyof typeof b]) { + return false + } + } + + return true +} + +const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {} + +function syncRepositoryIncrementally( + runtime: ExternalStoreThreadRuntimeCore, + messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']> +): readonly ThreadMessage[] { + const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository + const incomingIds = new Set(messageRepository.messages.map(({ message }) => message.id)) + + for (const { message, parentId } of messageRepository.messages) { + repository.addOrUpdateMessage(parentId, message) + } + + for (const { message } of repository.export().messages) { + if (!incomingIds.has(message.id)) { + repository.deleteMessage(message.id) + } + } + + const headId = messageRepository.headId ?? messageRepository.messages.at(-1)?.message.id ?? null + + repository.resetHead(headId) + + return repository.getMessages() +} + +class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore { + override __internal_setAdapter(store: ExternalStoreAdapter): void { + if (!store.messageRepository) { + super.__internal_setAdapter(store) + + return + } + + const self = this as unknown as { + _assistantOptimisticId: null | string + _capabilities: object + _messages: readonly ThreadMessage[] + _notifyEventSubscribers: (event: string, payload: object) => void + _notifySubscribers: () => void + _store?: ExternalStoreAdapter + } + + if (self._store === store) { + return + } + + const isRunning = store.isRunning ?? false + this.isDisabled = store.isDisabled ?? false + + const oldStore = self._store + self._store = store + + if (this.extras !== store.extras) { + this.extras = store.extras + } + + const newSuggestions = store.suggestions ?? EMPTY_ARRAY + + if (!shallowEqual(this.suggestions, newSuggestions)) { + this.suggestions = newSuggestions + } + + const newCapabilities = { + switchToBranch: store.setMessages !== undefined, + switchBranchDuringRun: false, + edit: store.onEdit !== undefined, + reload: store.onReload !== undefined, + cancel: store.onCancel !== undefined, + speech: store.adapters?.speech !== undefined, + dictation: store.adapters?.dictation !== undefined, + voice: store.adapters?.voice !== undefined, + unstable_copy: store.unstable_capabilities?.copy !== false, + attachments: !!store.adapters?.attachments, + feedback: !!store.adapters?.feedback, + queue: false + } + + if (!shallowEqual(self._capabilities, newCapabilities)) { + self._capabilities = newCapabilities + } + + if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) { + self._notifySubscribers() + + return + } + + if (self._assistantOptimisticId) { + this.repository.deleteMessage(self._assistantOptimisticId) + self._assistantOptimisticId = null + } + + const messages = syncRepositoryIncrementally(this, store.messageRepository) + + if (messages.length > 0) { + this.ensureInitialized() + } + + if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) { + self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {}) + } + + if (hasUpcomingMessage(isRunning, messages)) { + self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, { + role: 'assistant', + content: [] + }) + } + + this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null) + self._messages = this.repository.getMessages() + self._notifySubscribers() + } +} + +class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore { + threads: ExternalStoreThreadListRuntimeCore + + constructor(adapter: ExternalStoreAdapter) { + super() + + this.threads = new ExternalStoreThreadListRuntimeCore( + getThreadListAdapter(adapter), + () => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter) + ) + } + + setAdapter(adapter: ExternalStoreAdapter): void { + this.threads.__internal_setAdapter(getThreadListAdapter(adapter)) + this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter) + } +} + +export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>( + store: ExternalStoreAdapter<T> +): AssistantRuntime { + const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter)) + + useEffect(() => { + runtime.setAdapter(store as ExternalStoreAdapter) + }) + + const { modelContext } = useRuntimeAdapters() ?? {} + + useEffect(() => { + if (!modelContext) { + return undefined + } + + return runtime.registerModelContextProvider(modelContext) + }, [modelContext, runtime]) + + return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime]) +} diff --git a/apps/desktop/src/lib/katex-memo.ts b/apps/desktop/src/lib/katex-memo.ts new file mode 100644 index 000000000..7143fbff9 --- /dev/null +++ b/apps/desktop/src/lib/katex-memo.ts @@ -0,0 +1,260 @@ +/** + * Memoizing wrapper around `rehype-katex`. + * + * Why: the default `@streamdown/math` plugin runs `rehype-katex` on every + * markdown commit. During streaming, that means each new token re-runs + * KaTeX on EVERY math node in the message — including equations that + * haven't changed since the last token. For math-heavy responses (a + * model deriving an equation step-by-step) this becomes a major source + * of jank: 20 unchanged equations each pay ~5–20ms of katex.renderToString + * work per token, adding up to hundreds of ms of CPU bound work that + * delays the next streaming update. + * + * What this plugin does: walk the hast tree looking for the math nodes + * that `remark-math` emits (`<code class="math-inline">…</code>` for + * inline and `<pre><code class="math-display">…</code></pre>` for + * display), key them by `(displayMode, value)`, and serve them from an + * in-memory LRU cache when we've rendered the same equation before. + * Cache misses still go through `katex.renderToString`; cache hits + * return the previously generated hast subtree. + * + * Result: each unique equation only pays the katex cost once. Adding + * one new equation to a paragraph re-renders just that one equation + * instead of all of them. The cache is process-global so it survives + * moves between messages (e.g., re-rendering a session). + * + * Compatibility: the produced hast structure matches what `rehype-katex` + * itself produces — we use the same `hast-util-from-html-isomorphic` + * fragment parsing and the same parent-splice semantics, including the + * `<pre>`-walk-up for display mode. Drop-in replacement for the math + * slot in streamdown's PluginConfig. + * + * Wire it in via `createMemoizedMathPlugin`: + * + * import { createMemoizedMathPlugin } from '@/lib/katex-memo' + * const math = createMemoizedMathPlugin({ singleDollarTextMath: true }) + * <Streamdown plugins={{ math }} ... /> + */ + +import type { Element, ElementContent, Parent, Root } from 'hast' +import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic' +import { toText } from 'hast-util-to-text' +import katex from 'katex' +import remarkMath from 'remark-math' +import type { Pluggable } from 'unified' +import { SKIP, visitParents } from 'unist-util-visit-parents' +import type { VFile } from 'vfile' + +interface KatexMemoOptions { + /** + * Color used for KaTeX errors when we fall back to the lenient parser. + * Mirrors `@streamdown/math`'s default so the visual output is identical. + */ + errorColor?: string +} + +interface MathPluginConfig { + /** + * Match `singleDollarTextMath` from `@streamdown/math`. When true the + * remark-math parser treats `$x$` as inline math; when false it requires + * `$$x$$`. Models almost always emit the single-dollar form, so we + * default it to true at the createMemoizedMathPlugin call site. + */ + singleDollarTextMath?: boolean + errorColor?: string +} + +/** Cached rendered hast — children to splice into the math node's parent. */ +type CachedRender = ElementContent[] + +const CACHE_LIMIT = 512 + +class LruCache<K, V> { + private readonly map = new Map<K, V>() + + get(key: K): undefined | V { + const value = this.map.get(key) + + if (value === undefined) { + return undefined + } + + // Refresh recency by re-inserting at the tail. Map iteration order is + // insertion order, so the oldest entry is at the head. + this.map.delete(key) + this.map.set(key, value) + + return value + } + + set(key: K, value: V): void { + if (this.map.has(key)) { + this.map.delete(key) + } else if (this.map.size >= CACHE_LIMIT) { + const oldest = this.map.keys().next().value + + if (oldest !== undefined) { + this.map.delete(oldest) + } + } + + this.map.set(key, value) + } +} + +const cache = new LruCache<string, CachedRender>() + +function cacheKey(displayMode: boolean, value: string): string { + // `\u0001` is a control character that (a) won't appear in normal + // markdown and (b) is a single byte so the join is cheap. + return `${displayMode ? 'd' : 'i'}\u0001${value}` +} + +/** + * Render one math expression with the same two-pass strategy `rehype-katex` + * uses internally: try strict first (so genuine TeX errors get reported in + * the VFile message stream), and on failure fall back to lenient mode so + * the document still renders without a thrown exception. The lenient + * fallback paints the equation in `errorColor` instead of erroring out. + */ +function renderMath( + value: string, + displayMode: boolean, + errorColor: string, + file: VFile, + element: Element +): ElementContent[] { + let html: string + + try { + html = katex.renderToString(value, { displayMode, throwOnError: true }) + } catch (error) { + const cause = error as Error + + file.message('Could not render math with KaTeX', { + cause, + place: element.position, + ruleId: cause.name?.toLowerCase() ?? 'katex', + source: 'rehype-katex-memo' + }) + + try { + html = katex.renderToString(value, { + displayMode, + errorColor, + strict: 'ignore', + throwOnError: false + }) + } catch { + // Last-resort fallback — render the source text inside a styled span + // so the user at least sees what was supposed to be there. Mirrors + // rehype-katex's own escape hatch. + return [ + { + type: 'element', + tagName: 'span', + properties: { + className: ['katex-error'], + style: `color:${errorColor}`, + title: String(error) + }, + children: [{ type: 'text', value }] + } + ] + } + } + + const fragment = fromHtmlIsomorphic(html, { fragment: true }) + + return fragment.children as ElementContent[] +} + +/** + * The actual rehype plugin. Wraps `rehype-katex`'s logic with our LRU + * cache. Mirrors the upstream visitor exactly except for the cache lookup + * and an LRU.set on miss. + */ +function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable { + const errorColor = options.errorColor ?? 'var(--color-muted-foreground)' + + return () => + function transform(tree: Root, file: VFile): undefined { + visitParents(tree, 'element', (element, parents) => { + const classes = Array.isArray(element.properties?.className) ? (element.properties.className as string[]) : [] + + // Match the same class set rehype-katex looks for. `language-math` + // is the markdown ` ```math ` form, `math-inline` is what + // remark-math emits for `$x$`, `math-display` for `$$x$$`. + const languageMath = classes.includes('language-math') + const mathDisplay = classes.includes('math-display') + const mathInline = classes.includes('math-inline') + + if (!(languageMath || mathDisplay || mathInline)) { + return + } + + let displayMode = mathDisplay + let scope: Element = element + let parent: Parent | undefined = parents[parents.length - 1] + + // For ` ```math ` the scope walks up to the wrapping <pre> and + // we treat it as display math. Same logic rehype-katex uses. + if (languageMath && parent && parent.type === 'element' && (parent as Element).tagName === 'pre') { + scope = parent as Element + parent = parents[parents.length - 2] + displayMode = true + } + + // No parent means the math node is at the root — there's nothing + // to splice into, so bail. This shouldn't happen for properly + // nested markdown but is the same defensive guard rehype-katex has. + if (!parent) { + return + } + + const value = toText(scope, { whitespace: 'pre' }) + const key = cacheKey(displayMode, value) + let cached = cache.get(key) + + if (!cached) { + cached = renderMath(value, displayMode, errorColor, file, scope) + cache.set(key, cached) + } + + // Splice CLONES of the cached children into the parent. Reusing + // the same node instances across renders would let downstream + // rehype plugins or toJsxRuntime mutate the cached subtree — + // breaking the next cache hit. structuredClone is ~100µs per + // equation, well below the ~5–20ms katex.renderToString cost + // we're avoiding. + const clonedChildren = cached.map(child => structuredClone(child)) + const index = parent.children.indexOf(scope as ElementContent) + + if (index === -1) { + return + } + + parent.children.splice(index, 1, ...clonedChildren) + + return SKIP + }) + } +} + +/** + * Build a streamdown MathPlugin object that uses the memoized rehype-katex + * wrapper. Drop-in for `@streamdown/math`'s `createMathPlugin`. + */ +export function createMemoizedMathPlugin(config: MathPluginConfig = {}) { + const remarkPlugin: Pluggable = [remarkMath, { singleDollarTextMath: config.singleDollarTextMath ?? false }] + + const rehypePlugin = createMemoizedRehypeKatex({ errorColor: config.errorColor }) + + return { + name: 'katex' as const, + type: 'math' as const, + remarkPlugin, + rehypePlugin, + getStyles: () => 'katex/dist/katex.min.css' + } +} diff --git a/apps/desktop/src/lib/local-preview.ts b/apps/desktop/src/lib/local-preview.ts new file mode 100644 index 000000000..6c1816999 --- /dev/null +++ b/apps/desktop/src/lib/local-preview.ts @@ -0,0 +1,126 @@ +import type { PreviewTarget } from '@/store/preview' + +const HTML_EXTENSIONS = new Set(['.htm', '.html']) +const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']) + +const LANGUAGE_BY_EXT: Record<string, string> = { + '.c': 'c', + '.conf': 'ini', + '.cpp': 'cpp', + '.css': 'css', + '.csv': 'csv', + '.go': 'go', + '.graphql': 'graphql', + '.h': 'c', + '.hpp': 'cpp', + '.html': 'html', + '.java': 'java', + '.js': 'javascript', + '.json': 'json', + '.jsx': 'jsx', + '.log': 'text', + '.lua': 'lua', + '.md': 'markdown', + '.mjs': 'javascript', + '.py': 'python', + '.rb': 'ruby', + '.rs': 'rust', + '.sh': 'shell', + '.sql': 'sql', + '.svg': 'xml', + '.toml': 'toml', + '.ts': 'typescript', + '.tsx': 'tsx', + '.txt': 'text', + '.xml': 'xml', + '.yaml': 'yaml', + '.yml': 'yaml', + '.zsh': 'shell' +} + +function basename(value: string) { + return value.split(/[\\/]/).filter(Boolean).pop() || value +} + +function extension(value: string) { + const clean = value.split(/[?#]/, 1)[0] || value + const idx = clean.lastIndexOf('.') + + return idx >= 0 ? clean.slice(idx).toLowerCase() : '' +} + +function joinPath(base: string, rel: string) { + if (!base) { + return rel + } + + return `${base.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}` +} + +function pathToFileUrl(path: string) { + const encoded = path + .split('/') + .map(part => encodeURIComponent(part)) + .join('/') + + return `file://${encoded.startsWith('/') ? encoded : `/${encoded}`}` +} + +export function localPreviewTarget(rawTarget: string, cwd?: string | null): PreviewTarget | null { + const raw = rawTarget.trim().replace(/^`|`$/g, '') + + if (!raw) { + return null + } + + if (/^https?:\/\//i.test(raw)) { + return { kind: 'url', label: basename(raw), source: raw, url: raw } + } + + let path = raw + + if (/^file:\/\//i.test(raw)) { + try { + path = decodeURIComponent(new URL(raw).pathname) + } catch { + path = raw.replace(/^file:\/\//i, '') + } + } else if (!raw.startsWith('/') && cwd) { + path = joinPath(cwd, raw) + } + + const ext = extension(path) + const isHtml = HTML_EXTENSIONS.has(ext) + const isImage = IMAGE_EXTENSIONS.has(ext) + + return { + kind: 'file', + label: basename(path), + language: LANGUAGE_BY_EXT[ext] || 'text', + path, + // Renderer fallback can't stat/sniff without reading; assume text unless + // image/html extension says otherwise. LocalFilePreview still guards + // binary/large files when readFileText/readFileDataUrl returns metadata. + previewKind: isHtml ? 'html' : isImage ? 'image' : 'text', + source: raw, + url: pathToFileUrl(path) + } +} + +export async function normalizeOrLocalPreviewTarget( + rawTarget: string, + cwd?: string | null +): Promise<PreviewTarget | null> { + try { + const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined) + + if (normalized) { + return normalized + } + } catch { + // Running Electron may still have the old HTML-only preview IPC. Fall + // through to renderer-side local classification so text/images still open. + } + + return localPreviewTarget(rawTarget, cwd) +} diff --git a/apps/desktop/src/lib/markdown-code.test.ts b/apps/desktop/src/lib/markdown-code.test.ts new file mode 100644 index 000000000..f71f564c1 --- /dev/null +++ b/apps/desktop/src/lib/markdown-code.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { isLikelyProseCodeBlock } from './markdown-code' + +describe('isLikelyProseCodeBlock', () => { + it('detects prose that Streamdown mislabels as an unknown language', () => { + expect( + isLikelyProseCodeBlock( + 'heads', + [ + '- Pure white (`#ffffff`), roughness 0.55, no emissive', + '- Black wireframe edges at 35% opacity', + '', + 'Want the bunny gone, or want me to keep riffing on it?' + ].join('\n') + ) + ).toBe(true) + }) + + it('keeps real code blocks', () => { + expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts new file mode 100644 index 000000000..0b1057274 --- /dev/null +++ b/apps/desktop/src/lib/markdown-code.ts @@ -0,0 +1,195 @@ +const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i +const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown']) + +const COMMON_CODE_LANGUAGES = new Set([ + 'bash', + 'c', + 'cpp', + 'css', + 'diff', + 'go', + 'html', + 'java', + 'javascript', + 'js', + 'json', + 'jsx', + 'markdown', + 'md', + 'php', + 'python', + 'py', + 'ruby', + 'rust', + 'rs', + 'sh', + 'sql', + 'swift', + 'tsx', + 'ts', + 'typescript', + 'xml', + 'yaml', + 'yml' +]) + +interface CodeSignals { + bulletLines: number + codeSignals: number + hasMarkdown: boolean + proseLines: number + trimmed: string + urlLines: number +} + +export function sanitizeLanguageTag(tag: string): string { + const trimmed = tag.trim() + const first = trimmed.split(/\s/, 1)[0] || '' + + return VALID_LANGUAGE_RE.test(first) && first.length <= 16 ? first.toLowerCase() : '' +} + +// Sanitized language tag → codicon glyph. Anything not listed falls back to +// the generic `code` glyph, which matches what the tool-row icons use. +const CODICON_BY_LANGUAGE: Record<string, string> = { + bash: 'terminal', + cmd: 'terminal', + console: 'terminal', + fish: 'terminal', + powershell: 'terminal', + ps1: 'terminal', + sh: 'terminal', + shell: 'terminal', + zsh: 'terminal', + + md: 'markdown', + markdown: 'markdown', + + json: 'json', + json5: 'json', + + ini: 'settings-gear', + toml: 'settings-gear', + yaml: 'settings-gear', + yml: 'settings-gear', + dotenv: 'settings-gear', + env: 'settings-gear', + + graphql: 'database', + gql: 'database', + mysql: 'database', + postgres: 'database', + postgresql: 'database', + sql: 'database', + sqlite: 'database', + + diff: 'diff', + patch: 'diff', + + css: 'symbol-color', + less: 'symbol-color', + sass: 'symbol-color', + scss: 'symbol-color', + svg: 'symbol-color', + + regex: 'regex', + regexp: 'regex', + + curl: 'globe', + http: 'globe', + + docker: 'package', + dockerfile: 'package', + + mermaid: 'graph' +} + +export function codiconForLanguage(language: string | undefined): string { + return CODICON_BY_LANGUAGE[sanitizeLanguageTag(language || '')] || 'code' +} + +function proseLineCount(body: string): number { + return body.split('\n').filter(line => { + const trimmed = line.trim() + + return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed) + }).length +} + +const CODE_SIGNAL_RE = [ + /(^|\s)(const|let|var|function|class|import|export|return|if|for|while|switch)\b/gim, + /=>|==|===|!=|!==|\{|\}|;|<\/?[a-z][^>]*>/gi, + /^\s*(#include|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/gim +] + +function codeSignalCount(body: string): number { + return CODE_SIGNAL_RE.reduce((total, pattern) => total + (body.match(pattern)?.length ?? 0), 0) +} + +function codeSignals(body: string): CodeSignals { + const trimmed = body.trim() + const markdownSignals = (trimmed.match(/\*\*[^*]+\*\*/g) || []).length + (trimmed.match(/`[^`\n]+`/g) || []).length + + return { + bulletLines: (trimmed.match(/^\s*[-*]\s+\S+/gm) || []).length, + codeSignals: codeSignalCount(trimmed), + hasMarkdown: markdownSignals > 0, + proseLines: proseLineCount(trimmed), + trimmed, + urlLines: (trimmed.match(/^\s*https?:\/\/\S+\s*$/gim) || []).length + } +} + +export function isLikelyProseFence(info: string, body: string): boolean { + const trimmedInfo = info.trim() + const rawInfo = trimmedInfo.toLowerCase() + const language = sanitizeLanguageTag(info) + const infoToken = trimmedInfo.split(/\s+/, 1)[0] || '' + const hasInfoTail = Boolean(trimmedInfo) && trimmedInfo !== infoToken + + if (/^[-*+]\s/.test(rawInfo) || /^https?:\/\//.test(rawInfo)) { + return true + } + + const signals = codeSignals(body) + + if (!signals.trimmed) { + return false + } + + if ( + hasInfoTail && + signals.codeSignals <= 2 && + (signals.proseLines >= 2 || signals.bulletLines >= 1 || signals.urlLines >= 1) + ) { + return true + } + + if (!NON_CODE_FENCE_LANGUAGES.has(language)) { + return false + } + + return ( + (signals.bulletLines >= 2 && signals.hasMarkdown && signals.codeSignals <= 2) || + (signals.proseLines >= 3 && signals.codeSignals === 0) + ) +} + +export function isLikelyProseCodeBlock(language: string | undefined, code: string | undefined): boolean { + const cleanLanguage = sanitizeLanguageTag(language || '') + const signals = codeSignals(code || '') + + if (!signals.trimmed || signals.codeSignals >= 3) { + return false + } + + if (signals.bulletLines >= 1 && (signals.hasMarkdown || signals.proseLines >= 2)) { + return true + } + + if (NON_CODE_FENCE_LANGUAGES.has(cleanLanguage)) { + return signals.proseLines >= 3 && signals.codeSignals === 0 + } + + return !COMMON_CODE_LANGUAGES.has(cleanLanguage) && signals.proseLines >= 2 && signals.codeSignals <= 1 +} diff --git a/apps/desktop/src/lib/markdown-preprocess.ts b/apps/desktop/src/lib/markdown-preprocess.ts new file mode 100644 index 000000000..c4d4637be --- /dev/null +++ b/apps/desktop/src/lib/markdown-preprocess.ts @@ -0,0 +1,379 @@ +import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code' +import { stripPreviewTargets } from '@/lib/preview-targets' + +const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi +const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi + +const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/ +const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g +const CODE_FENCE_SPLIT_RE = /((?:```|~~~)[\s\S]*?(?:```|~~~))/g +const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g +const RAW_URL_RE = /https?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?]/g +const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/gi +const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i +const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i +const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu + +/** + * Returns true when `body` contains a line that's exactly `marker` (modulo + * leading/trailing horizontal whitespace) — i.e. an unambiguous close fence + * for an opening fence with the same marker. + * + * Implemented with string comparisons (not RegExp) so that input-derived + * `marker` values can never bleed into a regex pattern. This matters for + * CodeQL's `js/incomplete-hostname-regexp` dataflow, which would otherwise + * trace test-fixture URLs from the input through `marker` into the regex + * source, even though `marker` is captured by `(`{3,}|~{3,})` and can only + * ever be backticks or tildes. + */ +function hasCloseFenceLine(body: string, marker: string): boolean { + const lines = body.split('\n') + + // Original regex required `\n` immediately before the close fence, so the + // first line of `body` (which has no preceding newline within `body`) + // cannot itself be the close fence. + for (let i = 1; i < lines.length; i += 1) { + const line = lines[i] + let lo = 0 + let hi = line.length + + while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) { + lo += 1 + } + + while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) { + hi -= 1 + } + + if (line.slice(lo, hi) === marker) { + return true + } + } + + return false +} + +function scrubBacktickNoise(text: string): string { + const balancedFenceRe = /(^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g + const protectedRanges: { end: number; start: number }[] = [] + let match: RegExpExecArray | null + + while ((match = balancedFenceRe.exec(text)) !== null) { + const start = match.index + match[1].length + + protectedRanges.push({ end: balancedFenceRe.lastIndex, start }) + } + + const danglingCodeFenceRe = /(^|\n)[ \t]*(`{3,}|~{3,})([a-z0-9][a-z0-9+#-]{0,15})[ \t]*\n([\s\S]*)$/gi + + while ((match = danglingCodeFenceRe.exec(text)) !== null) { + const start = match.index + match[1].length + const marker = match[2] || '```' + const info = match[3] || '' + const body = match[4] || '' + + if (!hasCloseFenceLine(body, marker) && sanitizeLanguageTag(info) && !isLikelyProseFence(info, body)) { + protectedRanges.push({ end: text.length, start }) + + break + } + } + + protectedRanges.sort((a, b) => a.start - b.start) + + const fenceNoiseRe = /`{3,}/g + let out = '' + let cursor = 0 + + for (const range of protectedRanges) { + out += text.slice(cursor, range.start).replace(fenceNoiseRe, '') + out += text.slice(range.start, range.end) + cursor = range.end + } + + out += text.slice(cursor).replace(fenceNoiseRe, '') + + for (let pass = 0; pass < 2; pass += 1) { + // Match EXACTLY 2 backticks (not part of a longer run) on each side. + // Without the lookbehind/lookahead, two adjacent triple-backtick + // fences with only whitespace between them get spliced together — + // e.g. ```bash\n...\n```\n\n```latex matches the regex's + // last-2-of-bash-close + \n\n + first-2-of-latex-open and the + // surrounding fence markers collapse into a single longer block, + // which the markdown parser then treats as ONE giant code block. + out = out.replace(/(?<!`)``(?!`)\s*(?<!`)``(?!`)/g, '') + out = out.replace(/(^|[^`])``(?=\s|[.,;:!?)\]'"\u2014\u2013-]|$)/g, '$1') + } + + return out +} + +function stripEmptyFenceBlocks(text: string): string { + return text.replace(EMPTY_FENCE_BLOCK_RE, '$1') +} + +function isUrlOnlyBlock(lines: string[]): boolean { + const nonEmpty = lines.filter(line => line.trim()) + + return nonEmpty.length > 0 && nonEmpty.every(line => URL_ONLY_LINE_RE.test(line)) +} + +function autoLinkRawUrls(text: string): string { + return text.replace(RAW_URL_RE, (url: string, index: number) => { + const previous = text[index - 1] || '' + const beforePrevious = text[index - 2] || '' + + if (previous === '<' || (beforePrevious === ']' && previous === '(')) { + return url + } + + return `<${url}>` + }) +} + +function normalizeVisibleProse(text: string): string { + return text + .split(INLINE_CODE_SPLIT_RE) + .map(part => + part.startsWith('`') + ? part + : autoLinkRawUrls( + part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + ) + ) + .join('') +} + +function pushProseFence(out: string[], indent: string, info: string, lines: string[]) { + if (info) { + out.push(`${indent}${info}`.trimEnd()) + } + + out.push(...lines) +} + +function findClosingFence(lines: string[], start: number, marker: string): number { + for (let cursor = start + 1; cursor < lines.length; cursor += 1) { + const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE) + + if (!closeMatch) { + continue + } + + const closeMarker = closeMatch[2] || '' + const closeInfo = (closeMatch[3] || '').trim() + + if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) { + return cursor + } + } + + return -1 +} + +// Languages that should be routed to the math (KaTeX) renderer instead of +// being shown as a syntax-highlighted code block. +// +// We deliberately recognize ONLY `math` here, not `latex` or `tex`. +// Reasoning: GitHub-style markdown uses ` ```math ` to mean "render as +// math" and ` ```latex `/` ```tex ` to mean "show LaTeX/TeX source code" +// (syntax highlighted). Conflating the two breaks code blocks where a +// user is *discussing* LaTeX rather than embedding it (e.g., +// ```latex\n\begin{equation}\n E = mc^2\n\end{equation}``` shown as a +// teaching example). Anyone who wants math rendered should use ```math. +const MATH_FENCE_LANGUAGES = new Set(['math']) + +function isMathFence(language: string): boolean { + return MATH_FENCE_LANGUAGES.has(language.toLowerCase()) +} + +function normalizeFenceBlocks(text: string): string { + const sourceLines = text.split('\n') + const out: string[] = [] + let index = 0 + + while (index < sourceLines.length) { + const line = sourceLines[index] || '' + const match = line.match(FENCE_LINE_RE) + + if (!match) { + out.push(line) + index += 1 + + continue + } + + const indent = match[1] || '' + const marker = match[2] || '```' + const infoRaw = (match[3] || '').trim() + const languageToken = infoRaw.split(/\s+/, 1)[0] || '' + const language = sanitizeLanguageTag(languageToken) + const openerValid = !infoRaw || Boolean(language) + + if (!openerValid) { + out.push(`${indent}${infoRaw}`.trimEnd()) + index += 1 + + continue + } + + const closeIndex = findClosingFence(sourceLines, index, marker) + const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex) + const body = bodyLines.join('\n') + + if (closeIndex !== -1 && !body.trim()) { + index = closeIndex + 1 + + continue + } + + if (closeIndex !== -1 && LOCAL_PREVIEW_ONLY_RE.test(body.trim())) { + index = closeIndex + 1 + + continue + } + + if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) { + out.push(...bodyLines) + index = closeIndex + 1 + + continue + } + + if (closeIndex === -1) { + if (!body.trim()) { + index += 1 + + continue + } + + if (isLikelyProseFence(infoRaw, body)) { + pushProseFence(out, indent, infoRaw, bodyLines) + } else if (isMathFence(language)) { + // Streaming math fence — rewrite the language tag to "math". + // remark-math + rehype-katex pick up ```math fenced blocks via + // the language-math class on the resulting <code> element. We + // keep the fence intact (instead of converting to $$..$$) so + // any literal `$$` characters in the body don't collide with + // an outer math wrapper. No close emitted yet — streaming. + out.push(`${indent}${marker}math`) + out.push(...bodyLines) + } else { + out.push(`${indent}${marker}${language}`) + out.push(...bodyLines) + } + + break + } + + if (isLikelyProseFence(infoRaw, body)) { + pushProseFence(out, indent, infoRaw, bodyLines) + index = closeIndex + 1 + + continue + } + + if (isMathFence(language)) { + // Closed math fence — rewrite the language tag to "math" so + // rehype-katex's language-math class detection picks it up. + // Body stays untouched (no $$..$$ rewrite) so authors can write + // arbitrary LaTeX including `$$display$$` markers without them + // colliding with our wrapper. Without this rewrite the block + // would render as a syntax-highlighted "latex" code listing. + out.push(`${indent}${marker}math`) + out.push(...bodyLines) + out.push(`${indent}${marker}`) + index = closeIndex + 1 + + continue + } + + out.push(`${indent}${marker}${language}`) + out.push(...bodyLines) + out.push(`${indent}${marker}`) + index = closeIndex + 1 + } + + return out.join('\n') +} + +// Convert LaTeX bracket delimiters to remark-math's dollar-sign syntax. +// Models often emit `\(...\)` for inline math and `\[...\]` for display +// math (the standard LaTeX convention) instead of `$...$` / `$$...$$`. +// remark-math only natively recognizes the dollar form, so we rewrite at +// preprocess time. Done with simple non-greedy matches keyed on the +// escaped-bracket sequences — these are rare enough in non-math content +// (you'd have to write a literal `\(` followed eventually by a literal +// `\)` with NO interleaving newline-paragraph-break) that false positives +// are extremely unlikely. +const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g +const LATEX_DISPLAY_RE = /\\\[([\s\S]+?)\\\]/g + +function rewriteLatexBracketDelimiters(text: string): string { + return text + .replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`) + .replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`) +} + +// Escape `$<digit>` patterns so they don't get eaten as math delimiters. +// Models commonly write currency amounts ($5, $19.99, $1,299) in prose. +// With `singleDollarTextMath: true`, remark-math is greedy and matches +// EVERY pair of `$`s — including the open of `$5` to the next `$10`, +// rendering "5 in my pocket and you have " as italicized math text. +// The de-facto convention across math-supporting LLM UIs is to treat +// `$` followed by a digit as currency rather than math, since math +// expressions almost always start with a letter or `\command`. Trade- +// off: a math expression like `$5x = 10$` would have its leading 5 +// escaped — annoying but rare. The escape `\$` survives to render as +// a literal `$` in the final output. +const CURRENCY_DOLLAR_RE = /(^|[^\\])\$(?=\d)/g + +function escapeCurrencyDollars(text: string): string { + return text.replace(CURRENCY_DOLLAR_RE, '$1\\$') +} + +export function preprocessMarkdown(text: string): string { + const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '') + const scrubbed = scrubBacktickNoise(cleaned) + const normalizedFences = normalizeFenceBlocks(scrubbed) + const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences) + + return strippedEmptyFences + .split(CODE_FENCE_SPLIT_RE) + .map(part => { + // Fence blocks pass through untouched. + if (/^(?:```|~~~)/.test(part)) { + return part + } + + // Whitespace-only segments (e.g. the `\n\n` between two adjacent + // fences) must NOT go through stripPreviewTargets — its internal + // .trim() would collapse them to '' and glue the surrounding + // fences together, producing things like ``````math which the + // markdown parser then reads as a single 6-backtick block. + if (!part.trim()) { + return part + } + + // Preserve leading/trailing whitespace around the prose body so + // that fence-prose-fence sequences keep their blank-line gaps. + // stripPreviewTargets internally calls .trim() on its result for + // the benefit of its other (single-segment) callers; here we're + // operating on a SEGMENT of a larger document where outer + // whitespace is structural and must survive. + const leading = part.match(/^\s*/)?.[0] ?? '' + const trailing = part.match(/\s*$/)?.[0] ?? '' + + // rewriteLatexBracketDelimiters runs only on prose segments so + // we don't accidentally touch `\(` inside a code block. + // escapeCurrencyDollars likewise only runs on prose, so legit + // `$5` literals inside fenced code stay intact. + const transformed = normalizeVisibleProse( + stripPreviewTargets(rewriteLatexBracketDelimiters(escapeCurrencyDollars(part))) + ) + + return leading + transformed + trailing + }) + .join('') + .replace(/[ \t]+\n/g, '\n') +} diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts new file mode 100644 index 000000000..bf6fdf36a --- /dev/null +++ b/apps/desktop/src/lib/media.ts @@ -0,0 +1,90 @@ +export type MediaKind = 'audio' | 'image' | 'video' | 'file' + +interface MediaInfo { + kind: MediaKind + mime: string +} + +const MEDIA_BY_EXT: Record<string, MediaInfo> = { + avi: { kind: 'video', mime: 'video/x-msvideo' }, + bmp: { kind: 'image', mime: 'image/bmp' }, + flac: { kind: 'audio', mime: 'audio/flac' }, + gif: { kind: 'image', mime: 'image/gif' }, + jpeg: { kind: 'image', mime: 'image/jpeg' }, + jpg: { kind: 'image', mime: 'image/jpeg' }, + m4a: { kind: 'audio', mime: 'audio/mp4' }, + mkv: { kind: 'video', mime: 'video/x-matroska' }, + mov: { kind: 'video', mime: 'video/quicktime' }, + mp3: { kind: 'audio', mime: 'audio/mpeg' }, + mp4: { kind: 'video', mime: 'video/mp4' }, + ogg: { kind: 'audio', mime: 'audio/ogg' }, + opus: { kind: 'audio', mime: 'audio/ogg; codecs=opus' }, + png: { kind: 'image', mime: 'image/png' }, + svg: { kind: 'image', mime: 'image/svg+xml' }, + wav: { kind: 'audio', mime: 'audio/wav' }, + webm: { kind: 'video', mime: 'video/webm' }, + webp: { kind: 'image', mime: 'image/webp' } +} + +function mediaInfo(path: string): MediaInfo | undefined { + const ext = path.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase() + + return ext ? MEDIA_BY_EXT[ext] : undefined +} + +export function mediaKind(path: string): MediaKind { + return mediaInfo(path)?.kind ?? 'file' +} + +export function mediaMime(path: string): string { + return mediaInfo(path)?.mime ?? 'application/octet-stream' +} + +export function mediaName(path: string): string { + try { + const url = new URL(path) + + return url.pathname.split('/').filter(Boolean).pop() || path + } catch { + return path.split(/[\\/]/).filter(Boolean).pop() || path + } +} + +export function mediaMarkdownHref(path: string): string { + return `#media:${encodeURIComponent(path)}` +} + +export function mediaExternalUrl(path: string): string { + return /^(?:https?|file):/i.test(path) ? path : `file://${path}` +} + +export function mediaPathFromMarkdownHref(href?: string): string | null { + if (!href?.startsWith('#media:')) { + return null + } + + try { + return decodeURIComponent(href.slice('#media:'.length)) + } catch { + return null + } +} + +export function filePathFromMediaPath(path: string): string { + if (!path.startsWith('file:')) { + return path + } + + try { + return decodeURIComponent(new URL(path).pathname) + } catch { + return path.replace(/^file:\/\//, '') + } +} + +export function mediaDisplayLabel(path: string): string { + const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&') + const kind = mediaKind(path) + + return `${kind[0].toUpperCase()}${kind.slice(1)}: ${escaped}` +} diff --git a/apps/desktop/src/lib/preview-targets.test.ts b/apps/desktop/src/lib/preview-targets.test.ts new file mode 100644 index 000000000..20a116f8f --- /dev/null +++ b/apps/desktop/src/lib/preview-targets.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { extractPreviewTargets, previewTargetFromMarkdownHref, stripPreviewTargets } from './preview-targets' + +describe('preview target detection', () => { + it('does not infer preview targets from raw paths or URLs', () => { + expect(extractPreviewTargets('Preview: http://localhost:5173/')).toEqual([]) + expect(extractPreviewTargets('Open index.html\n/tmp/demo.html\nhttp://localhost:5173/')).toEqual([]) + }) + + it('decodes preview markdown hrefs', () => { + expect(previewTargetFromMarkdownHref('#preview/%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html') + expect(previewTargetFromMarkdownHref('#preview:%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html') + expect(previewTargetFromMarkdownHref('#media:%2Ftmp%2Fdemo.mp4')).toBeNull() + }) + + it('extracts preview targets from already-rendered preview markers', () => { + expect(extractPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)')).toEqual(['/tmp/demo.html']) + }) + + it('strips preview targets from visible assistant text', () => { + expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe( + 'ready\n/tmp/mycelium-bunnies.html\nopen it' + ) + expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it') + }) +}) diff --git a/apps/desktop/src/lib/preview-targets.ts b/apps/desktop/src/lib/preview-targets.ts new file mode 100644 index 000000000..bc7108abd --- /dev/null +++ b/apps/desktop/src/lib/preview-targets.ts @@ -0,0 +1,63 @@ +const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi + +export function stripPreviewTargets(text: string): string { + return text + .replace(PREVIEW_MARKDOWN_RE, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +export function extractPreviewTargets(text: string): string[] { + const targets: string[] = [] + const seen = new Set<string>() + + for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) { + const target = previewTargetFromMarkdownHref(match.groups?.href) + + if (target && !seen.has(target)) { + seen.add(target) + targets.push(target) + } + } + + return targets +} + +export function previewMarkdownHref(target: string): string { + return `#preview/${encodeURIComponent(target)}` +} + +export function previewTargetFromMarkdownHref(href?: string): string | null { + if (!href?.startsWith('#preview:') && !href?.startsWith('#preview/')) { + return null + } + + try { + return decodeURIComponent(href.slice('#preview'.length + 1)) + } catch { + return null + } +} + +export function previewName(target: string): string { + try { + const url = new URL(target) + + if (url.protocol === 'file:') { + return decodeURIComponent(url.pathname).split(/[\\/]/).filter(Boolean).pop() || target + } + + const file = url.pathname.split('/').filter(Boolean).pop() + + return file || url.host + } catch { + return target.split(/[\\/]/).filter(Boolean).pop() || target + } +} + +export function previewDisplayLabel(target: string): string { + const escaped = previewName(target).replace(/[[\]\\]/g, '\\$&') + + return `Preview: ${escaped}` +} diff --git a/apps/desktop/src/lib/provider-setup-errors.test.ts b/apps/desktop/src/lib/provider-setup-errors.test.ts new file mode 100644 index 000000000..b90cdecb4 --- /dev/null +++ b/apps/desktop/src/lib/provider-setup-errors.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { isProviderSetupErrorMessage } from './provider-setup-errors' + +describe('isProviderSetupErrorMessage', () => { + it('matches generic missing-provider copy', () => { + expect(isProviderSetupErrorMessage('No inference provider configured. Run `hermes model` to choose one.')).toBe( + true + ) + expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true) + expect(isProviderSetupErrorMessage('No Hermes provider is configured.')).toBe(true) + expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true) + }) + + it('does not match non-provider runtime failures', () => { + expect( + isProviderSetupErrorMessage('Selected runtime is not available. setup.status reports configured credentials.') + ).toBe(false) + }) + + it('returns false for empty input', () => { + expect(isProviderSetupErrorMessage('')).toBe(false) + expect(isProviderSetupErrorMessage(null)).toBe(false) + expect(isProviderSetupErrorMessage(undefined)).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/provider-setup-errors.ts b/apps/desktop/src/lib/provider-setup-errors.ts new file mode 100644 index 000000000..190e73933 --- /dev/null +++ b/apps/desktop/src/lib/provider-setup-errors.ts @@ -0,0 +1,12 @@ +const PROVIDER_SETUP_ERROR_RE = + /No (?:inference|Hermes) provider(?: is)? configured|no_provider_configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i + +export function isProviderSetupErrorMessage(message: null | string | undefined): boolean { + const text = message?.trim() + + if (!text) { + return false + } + + return PROVIDER_SETUP_ERROR_RE.test(text) +} diff --git a/apps/desktop/src/lib/runtime-readiness.test.ts b/apps/desktop/src/lib/runtime-readiness.test.ts new file mode 100644 index 000000000..54a25828c --- /dev/null +++ b/apps/desktop/src/lib/runtime-readiness.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { interpretRuntimeReadiness } from './runtime-readiness' + +describe('interpretRuntimeReadiness', () => { + it('prefers runtime_check when both signals exist', () => { + const result = interpretRuntimeReadiness({ + setup: { provider_configured: false }, + setupError: null, + runtime: { ok: true }, + runtimeError: null + }) + + expect(result).toEqual({ + checksDisagree: true, + ready: true, + reason: null, + source: 'runtime_check' + }) + }) + + it('surfaces runtime mismatch details when runtime_check fails', () => { + const result = interpretRuntimeReadiness({ + setup: { provider_configured: true }, + setupError: null, + runtime: { error: 'No provider can serve the selected model.', ok: false }, + runtimeError: null + }) + + expect(result.ready).toBe(false) + expect(result.source).toBe('runtime_check') + expect(result.checksDisagree).toBe(true) + expect(result.reason).toContain('No provider can serve the selected model.') + expect(result.reason).toContain('setup.status reports configured credentials') + }) + + it('falls back to setup.status when runtime_check has no boolean result', () => { + const result = interpretRuntimeReadiness({ + setup: { provider_configured: true }, + setupError: null, + runtime: null, + runtimeError: 'runtime check RPC unavailable' + }) + + expect(result).toEqual({ + checksDisagree: false, + ready: true, + reason: null, + source: 'setup_status' + }) + }) + + it('uses explicit fallback when both checks are missing', () => { + const result = interpretRuntimeReadiness({ + setup: null, + setupError: 'setup.status timeout', + runtime: null, + runtimeError: 'setup.runtime_check timeout' + }) + + expect(result.ready).toBe(false) + expect(result.source).toBe('fallback') + expect(result.reason).toBe('setup.runtime_check timeout') + }) +}) diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts new file mode 100644 index 000000000..47f3406ea --- /dev/null +++ b/apps/desktop/src/lib/runtime-readiness.ts @@ -0,0 +1,147 @@ +export interface SetupStatusSnapshot { + provider_configured?: boolean +} + +export interface RuntimeCheckSnapshot { + error?: string + ok?: boolean +} + +export interface RuntimeReadinessSignals { + setup: null | SetupStatusSnapshot + setupError: null | string + runtime: null | RuntimeCheckSnapshot + runtimeError: null | string +} + +export interface RuntimeReadinessOptions { + defaultReason?: string + unknownReady?: boolean +} + +export interface RuntimeReadinessResult { + checksDisagree: boolean + ready: boolean + reason: null | string + source: 'fallback' | 'runtime_check' | 'setup_status' +} + +export type RuntimeReadinessRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> + +const DEFAULT_NOT_READY_REASON = 'Add a provider credential before sending your first message.' + +function toErrorMessage(error: unknown): null | string { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'string') { + return error + } + + if (error === null || error === undefined) { + return null + } + + return String(error) +} + +function normalizeMessage(value: null | string | undefined): null | string { + const next = value?.trim() + + return next ? next : null +} + +async function requestWithFallback<T>( + requestGateway: RuntimeReadinessRequester, + method: string +): Promise<{ error: null | string; value: null | T }> { + try { + return { error: null, value: await requestGateway<T>(method) } + } catch (error) { + return { error: toErrorMessage(error), value: null } + } +} + +export async function fetchRuntimeReadinessSignals( + requestGateway: RuntimeReadinessRequester +): Promise<RuntimeReadinessSignals> { + const [setup, runtime] = await Promise.all([ + requestWithFallback<SetupStatusSnapshot>(requestGateway, 'setup.status'), + requestWithFallback<RuntimeCheckSnapshot>(requestGateway, 'setup.runtime_check') + ]) + + return { + setup: setup.value, + setupError: setup.error, + runtime: runtime.value, + runtimeError: runtime.error + } +} + +export function interpretRuntimeReadiness( + signals: RuntimeReadinessSignals, + options: RuntimeReadinessOptions = {} +): RuntimeReadinessResult { + const defaultReason = options.defaultReason ?? DEFAULT_NOT_READY_REASON + const unknownReady = options.unknownReady ?? false + + const setupConfigured = + typeof signals.setup?.provider_configured === 'boolean' ? Boolean(signals.setup.provider_configured) : undefined + + const runtimeOk = typeof signals.runtime?.ok === 'boolean' ? Boolean(signals.runtime.ok) : undefined + const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError) + const setupFailure = normalizeMessage(signals.setupError) + + const checksDisagree = + typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk + + if (typeof runtimeOk === 'boolean') { + if (runtimeOk) { + return { + checksDisagree, + ready: true, + reason: null, + source: 'runtime_check' + } + } + + let reason = runtimeFailure ?? defaultReason + + if (checksDisagree && setupConfigured) { + reason = `${reason} setup.status reports configured credentials, but runtime resolution still failed.` + } + + return { + checksDisagree, + ready: false, + reason, + source: 'runtime_check' + } + } + + if (typeof setupConfigured === 'boolean') { + return { + checksDisagree: false, + ready: setupConfigured, + reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason), + source: 'setup_status' + } + } + + return { + checksDisagree: false, + ready: unknownReady, + reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason), + source: 'fallback' + } +} + +export async function evaluateRuntimeReadiness( + requestGateway: RuntimeReadinessRequester, + options: RuntimeReadinessOptions = {} +): Promise<RuntimeReadinessResult> { + const signals = await fetchRuntimeReadinessSignals(requestGateway) + + return interpretRuntimeReadiness(signals, options) +} diff --git a/apps/desktop/src/lib/session-export.ts b/apps/desktop/src/lib/session-export.ts new file mode 100644 index 000000000..677ed783d --- /dev/null +++ b/apps/desktop/src/lib/session-export.ts @@ -0,0 +1,56 @@ +import type { SessionInfo } from '@/hermes' +import { getSessionMessages } from '@/hermes' +import { notify, notifyError } from '@/store/notifications' + +interface ExportSessionParams { + sessionId: string + title?: string | null + session?: SessionInfo +} + +function sanitizeFilenamePart(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) +} + +function sessionExportFilename(sessionId: string, title?: string | null) { + const titlePart = title ? sanitizeFilenamePart(title) : '' + const idPart = sanitizeFilenamePart(sessionId).slice(0, 8) || 'session' + + return `${titlePart || 'session'}-${idPart}.json` +} + +export async function exportSession(sessionId: string, params: Omit<ExportSessionParams, 'sessionId'> = {}) { + if (!sessionId) { + return + } + + try { + const { messages } = await getSessionMessages(sessionId) + + const payload = { + exported_at: new Date().toISOString(), + session_id: sessionId, + title: params.title ?? null, + session: params.session ?? null, + message_count: messages.length, + messages + } + + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) + const downloadUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = downloadUrl + anchor.download = sessionExportFilename(sessionId, params.title) + anchor.click() + URL.revokeObjectURL(downloadUrl) + + notify({ kind: 'success', message: 'Session exported', durationMs: 2_000 }) + } catch (err) { + notifyError(err, 'Could not export session') + } +} diff --git a/apps/desktop/src/lib/speech-text.ts b/apps/desktop/src/lib/speech-text.ts new file mode 100644 index 000000000..3d0769819 --- /dev/null +++ b/apps/desktop/src/lib/speech-text.ts @@ -0,0 +1,35 @@ +const EMOJI_RE = /(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu + +const FENCED_CODE_RE = /```[\s\S]*?(?:```|$)/g +const INLINE_CODE_RE = /`([^`]+)`/g +const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g +const PARAGRAPH_BREAK_RE = /[ \t]*\n{2,}[ \t]*/g +const SOFT_BREAK_RE = /[ \t]*\n[ \t]*/g + +const THINKING_PREFIX_RE = + /^\s*(?:\([^)\n]{1,48}\)\s*)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i + +const URL_RE = /\bhttps?:\/\/\S+/gi + +function normalizeLineBreaks(text: string): string { + return text + .replace(/\r\n?/g, '\n') + .replace(/(\p{L})-\n(\p{L})/gu, '$1$2') + .replace(PARAGRAPH_BREAK_RE, '. ') + .replace(SOFT_BREAK_RE, ' ') +} + +export function sanitizeTextForSpeech(text: string): string { + return normalizeLineBreaks(text) + .replace(FENCED_CODE_RE, ' ') + .replace(THINKING_PREFIX_RE, ' ') + .replace(MARKDOWN_LINK_RE, '$1') + .replace(INLINE_CODE_RE, '$1') + .replace(URL_RE, ' link ') + .replace(EMOJI_RE, ' ') + .replace(/^#{1,6}\s+/gm, '') + .replace(/[*_~>#]/g, '') + .replace(/^\s*[-+*]\s+/gm, '') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/apps/desktop/src/lib/statusbar.ts b/apps/desktop/src/lib/statusbar.ts new file mode 100644 index 000000000..8cd7ea2f6 --- /dev/null +++ b/apps/desktop/src/lib/statusbar.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react' + +import type { UsageStats } from '@/types/hermes' + +export function formatK(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return '0' + } + + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M` + } + + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k` + } + + return `${Math.round(value)}` +} + +export function formatDuration(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)) + const seconds = totalSeconds % 60 + const minutes = Math.floor(totalSeconds / 60) % 60 + const hours = Math.floor(totalSeconds / 3600) + const ss = String(seconds).padStart(2, '0') + const mm = String(minutes).padStart(2, '0') + + return hours > 0 ? `${hours}:${mm}:${ss}` : `${minutes}:${ss}` +} + +export function compactPath(path: string, max = 44): string { + const trimmed = path.trim() + + if (trimmed.length <= max) { + return trimmed + } + + const segments = trimmed.split('/').filter(Boolean) + + if (segments.length < 2) { + return `…${trimmed.slice(-(max - 1))}` + } + + const tail = segments.slice(-2).join('/') + + return tail.length + 2 >= max ? `…${tail.slice(-(max - 1))}` : `…/${tail}` +} + +export function contextBar(percent: number | undefined, width = 10): string { + const bounded = Math.max(0, Math.min(100, percent ?? 0)) + const filled = Math.round((bounded / 100) * width) + + return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}` +} + +export function usageContextLabel(usage: UsageStats): string { + if (usage.context_max) { + return `${formatK(usage.context_used ?? 0)}/${formatK(usage.context_max)}` + } + + return usage.total > 0 ? `${formatK(usage.total)} tok` : '' +} + +export function contextBarLabel(usage: UsageStats): string { + if (!usage.context_max) { + return '' + } + + const pct = Math.max(0, Math.min(100, Math.round(usage.context_percent ?? 0))) + + return `[${contextBar(usage.context_percent)}] ${pct}%` +} + +export function LiveDuration({ since }: { since: number | null | undefined }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!since) { + return + } + + const tick = () => setNow(Date.now()) + tick() + const timer = window.setInterval(tick, 1000) + + return () => window.clearInterval(timer) + }, [since]) + + return since ? formatDuration(now - since) : null +} diff --git a/apps/desktop/src/lib/storage.ts b/apps/desktop/src/lib/storage.ts new file mode 100644 index 000000000..8174c9361 --- /dev/null +++ b/apps/desktop/src/lib/storage.ts @@ -0,0 +1,77 @@ +export function storedBoolean(key: string, fallback: boolean): boolean { + try { + const value = window.localStorage.getItem(key) + + return value === null ? fallback : value === 'true' + } catch { + return fallback + } +} + +export function persistBoolean(key: string, value: boolean) { + try { + window.localStorage.setItem(key, String(value)) + } catch { + // Local storage is a convenience; ignore failures in restricted contexts. + } +} + +export function storedString(key: string): null | string { + try { + return window.localStorage.getItem(key) + } catch { + return null + } +} + +export function persistString(key: string, value: null | string) { + try { + if (value === null) { + window.localStorage.removeItem(key) + } else { + window.localStorage.setItem(key, value) + } + } catch { + // Storage is best-effort. + } +} + +export function storedStringArray(key: string): string[] { + try { + const value = window.localStorage.getItem(key) + + if (!value) { + return [] + } + + const parsed = JSON.parse(value) + + if (!Array.isArray(parsed)) { + return [] + } + + return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0) + } catch { + return [] + } +} + +export function persistStringArray(key: string, value: string[]) { + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Pins are a local preference; restricted storage should not break chat. + } +} + +export function arraysEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((item, index) => item === right[index]) +} + +export function insertUniqueId(ids: string[], id: string, index: number) { + const next = ids.filter(item => item !== id) + const boundedIndex = Math.min(Math.max(index, 0), next.length) + next.splice(boundedIndex, 0, id) + + return next +} diff --git a/apps/desktop/src/lib/todos.test.ts b/apps/desktop/src/lib/todos.test.ts new file mode 100644 index 000000000..ebd296ab7 --- /dev/null +++ b/apps/desktop/src/lib/todos.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { parseTodos } from './todos' + +describe('parseTodos', () => { + it('parses todo arrays with valid ids, content, and statuses', () => { + expect( + parseTodos([ + { content: 'Gather ingredients', id: 'prep', status: 'completed' }, + { content: 'Boil water', id: 'boil', status: 'in_progress' }, + { content: 'Serve', id: 'serve', status: 'pending' } + ]) + ).toEqual([ + { content: 'Gather ingredients', id: 'prep', status: 'completed' }, + { content: 'Boil water', id: 'boil', status: 'in_progress' }, + { content: 'Serve', id: 'serve', status: 'pending' } + ]) + }) + + it('parses nested todo payloads from wrapped objects and JSON strings', () => { + expect(parseTodos({ todos: [{ content: 'Plate', id: 'plate', status: 'pending' }] })).toEqual([ + { content: 'Plate', id: 'plate', status: 'pending' } + ]) + + expect(parseTodos('{"todos":[{"id":"plate","content":"Plate","status":"pending"}]}')).toEqual([ + { content: 'Plate', id: 'plate', status: 'pending' } + ]) + }) + + it('returns null for non-todo payloads', () => { + expect(parseTodos(undefined)).toBeNull() + expect(parseTodos('not json')).toBeNull() + expect(parseTodos({ message: 'no todos here' })).toBeNull() + }) +}) diff --git a/apps/desktop/src/lib/todos.ts b/apps/desktop/src/lib/todos.ts new file mode 100644 index 000000000..56f36b45c --- /dev/null +++ b/apps/desktop/src/lib/todos.ts @@ -0,0 +1,51 @@ +export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled' + +export interface TodoItem { + content: string + id: string + status: TodoStatus +} + +const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled'] + +const isRecord = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === 'object' && !Array.isArray(v)) +const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]).includes(v as string) + +function parseArray(value: unknown[]): TodoItem[] { + return value.flatMap(item => { + if (!isRecord(item) || !isStatus(item.status)) { + return [] + } + + const id = String(item.id ?? '').trim() + const content = String(item.content ?? '').trim() + + return id && content ? [{ content, id, status: item.status }] : [] + }) +} + +function parse(value: unknown, depth: number): null | TodoItem[] { + if (depth > 2) { + return null + } + + if (Array.isArray(value)) { + return parseArray(value) + } + + if (typeof value === 'string' && value.trim()) { + try { + return parse(JSON.parse(value), depth + 1) + } catch { + return null + } + } + + if (isRecord(value) && Object.hasOwn(value, 'todos')) { + return parse(value.todos, depth + 1) + } + + return null +} + +export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0) diff --git a/apps/desktop/src/lib/tool-result-summary.test.ts b/apps/desktop/src/lib/tool-result-summary.test.ts new file mode 100644 index 000000000..fc095db6e --- /dev/null +++ b/apps/desktop/src/lib/tool-result-summary.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' + +import { extractToolErrorMessage, formatToolResultSummary } from './tool-result-summary' + +describe('formatToolResultSummary', () => { + it('unwraps wrapper payloads into structured key-value lines', () => { + const summary = formatToolResultSummary({ + success: true, + result: { + data: { + path: '/tmp/demo.txt', + status: 'ok', + lines_written: 12, + checksum: 'abc123' + } + } + }) + + expect(summary).toContain('- Path: /tmp/demo.txt') + expect(summary).toContain('- Status: ok') + expect(summary).toContain('- Lines Written: 12') + expect(summary).not.toContain('"path"') + }) + + it('summarizes object arrays as readable list items', () => { + const summary = formatToolResultSummary([ + { title: 'First result', snippet: 'alpha preview text' }, + { title: 'Second result', status: 'cached' }, + { title: 'Third result', summary: 'more details' }, + { title: 'Fourth result', summary: 'line 4' }, + { title: 'Fifth result', summary: 'line 5' }, + { title: 'Sixth result', summary: 'line 6' }, + { title: 'Seventh result', summary: 'line 7' } + ]) + + expect(summary).toContain('- First result - alpha preview text') + expect(summary).toContain('- Second result (cached)') + expect(summary).toContain('- … 1 more item') + }) + + it('truncates long field values for compact display', () => { + const summary = formatToolResultSummary({ + message: 'ok', + details: `prefix ${'x'.repeat(500)}` + }) + + const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:')) + + expect(detailsLine).toBeTruthy() + expect(detailsLine?.length).toBeLessThan(230) + expect(detailsLine).toContain('…') + }) + + it('formats stringified json payloads without raw dumps', () => { + const summary = formatToolResultSummary( + JSON.stringify({ + data: { + title: 'Build report', + completed: true + } + }) + ) + + expect(summary).toContain('- Title: Build report') + expect(summary).toContain('- Completed: true') + }) +}) + +describe('extractToolErrorMessage', () => { + it('finds nested error messages through wrappers', () => { + const error = extractToolErrorMessage({ + success: false, + result: { + output: { + error: { + message: 'Permission denied writing /tmp/demo.txt' + } + } + } + }) + + expect(error).toBe('Permission denied writing /tmp/demo.txt') + }) + + it('does not treat successful payload messages as errors', () => { + const error = extractToolErrorMessage({ + success: true, + message: 'Completed successfully', + data: { count: 3 } + }) + + expect(error).toBe('') + }) + + it('ignores placeholder error fields in successful payloads', () => { + const error = extractToolErrorMessage({ + success: true, + data: { + error: 'none', + status: 'ok' + } + }) + + expect(error).toBe('') + }) +}) diff --git a/apps/desktop/src/lib/tool-result-summary.ts b/apps/desktop/src/lib/tool-result-summary.ts new file mode 100644 index 000000000..22af56444 --- /dev/null +++ b/apps/desktop/src/lib/tool-result-summary.ts @@ -0,0 +1,463 @@ +// Heuristic JSON → human summary for tool results. Default view; technical +// mode still gets the raw JSON section. + +const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const + +const PRIORITY_KEYS = [ + 'title', + 'name', + 'path', + 'file', + 'filepath', + 'url', + 'href', + 'link', + 'status', + 'id', + 'message', + 'summary', + 'description' +] as const + +const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const +const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const +const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na']) + +type Json = Record<string, unknown> + +const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' && !Array.isArray(v)) + +function tryJson(value: string): unknown { + const t = value.trim() + + if (!t) { + return '' + } + + if (!/^[{[]|^"/.test(t)) { + return value + } + + try { + return JSON.parse(t) + } catch { + return value + } +} + +const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v) + +const titleCase = (k: string) => + k + .split(/[_\-.]+/) + .filter(Boolean) + .map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`) + .join(' ') + +const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}` + +function clipInline(value: string, max = 180): string { + const c = value.replace(/\s+/g, ' ').trim() + + return c.length > max ? `${c.slice(0, max - 1)}…` : c +} + +function clipBlock(value: string, maxChars = 1800, maxLines = 18): string { + const t = value.trim() + + if (!t) { + return '' + } + + const lines = t.split('\n') + let text = lines.slice(0, maxLines).join('\n') + const clipped = lines.length > maxLines || text.length > maxChars + + if (text.length > maxChars) { + text = text.slice(0, maxChars - 1).trimEnd() + } + + return clipped && !text.endsWith('…') ? `${text}…` : text +} + +function firstString(record: Json, keys: readonly string[]): string { + for (const k of keys) { + const v = record[k] + + if (typeof v === 'string' && v.trim()) { + return v.trim() + } + } + + return '' +} + +function orderedKeys(keys: string[]): string[] { + const priority = PRIORITY_KEYS.filter(k => keys.includes(k)) + const rest = keys.filter(k => !priority.includes(k as never)) + + return [...priority, ...rest] +} + +const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes(k) +const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true) + +function summarizeScalar(v: unknown): string { + if (typeof v === 'string') { + return clipInline(v) + } + + if (typeof v === 'number' || typeof v === 'boolean') { + return String(v) + } + + return '' +} + +function summarizeRecordInline(record: Json, depth: number): string { + if (depth > 3) { + return pluralize(Object.keys(record).length, 'field') + } + + const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id']) + const status = firstString(record, ['status', 'category', 'type']) + const message = firstString(record, ['snippet', 'summary', 'description', 'message']) + + if (title && status) { + return `${clipInline(title, 110)} (${clipInline(status, 54)})` + } + + if (title && message && title !== message) { + return `${clipInline(title, 90)} - ${clipInline(message, 84)}` + } + + if (title) { + return clipInline(title, 150) + } + + const pairs = orderedKeys(Object.keys(record)) + .filter(k => !skipField(k, record[k])) + .map(k => { + const s = summarizeScalar(record[k]) + + return s ? `${titleCase(k)}: ${s}` : '' + }) + .filter(Boolean) + .slice(0, 2) + + return pairs.length ? pairs.join(' · ') : pluralize(Object.keys(record).length, 'field') +} + +function summarizeListItem(item: unknown, depth: number): string { + const v = norm(item) + + if (typeof v === 'string') { + return clipInline(v) + } + + if (typeof v === 'number' || typeof v === 'boolean') { + return String(v) + } + + if (v == null) { + return '' + } + + if (Array.isArray(v)) { + return pluralize(v.length, 'item') + } + + if (isRecord(v)) { + return summarizeRecordInline(v, depth + 1) + } + + return clipInline(String(v)) +} + +function formatFieldValue(value: unknown, depth: number): string { + const v = norm(value) + const scalar = summarizeScalar(v) + + if (scalar) { + return scalar + } + + if (v == null) { + return '' + } + + if (Array.isArray(v)) { + if (!v.length) { + return '' + } + + const scalars = v.map(summarizeScalar).filter(Boolean) + + if (scalars.length === v.length && v.length <= 4) { + return clipInline(scalars.join(', ')) + } + + const first = summarizeListItem(v[0], depth + 1) + + return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item') + } + + if (isRecord(v)) { + return summarizeRecordInline(v, depth + 1) + } + + return clipInline(String(v)) +} + +// "Returned N items" / "0 items" / "Returned an empty object" are all +// noise — better to render nothing and let the title carry the signal. +function formatArraySummary(value: unknown[], depth: number): string { + if (!value.length) { + return '' + } + + const max = 6 + + const lines = value + .slice(0, max) + .map(item => summarizeListItem(item, depth + 1)) + .filter(Boolean) + .map(l => `- ${l}`) + + if (!lines.length) { + return '' + } + + if (value.length > max) { + const remaining = value.length - max + lines.push(`- … ${remaining} more ${remaining === 1 ? 'item' : 'items'}`) + } + + return lines.join('\n') +} + +function formatRecordSummary(record: Json, depth: number): string { + const keys = Object.keys(record) + + if (!keys.length) { + return '' + } + + if (depth <= 2) { + const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content']) + const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k)) + + if (direct && meaningful.length <= 1) { + return clipBlock(direct) + } + } + + const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k])) + const max = 8 + const lines: string[] = [] + + for (const k of candidates) { + const v = formatFieldValue(record[k], depth + 1) + + if (!v) { + continue + } + + lines.push(`- ${titleCase(k)}: ${v}`) + + if (lines.length >= max) { + break + } + } + + if (!lines.length) { + return '' + } + + if (candidates.length > lines.length) { + const remaining = candidates.length - lines.length + lines.push(`- … ${remaining} more ${remaining === 1 ? 'field' : 'fields'}`) + } + + return lines.join('\n') +} + +function formatSummaryValue(value: unknown, depth: number): string { + if (depth > 4) { + return '' + } + + const v = norm(value) + + if (typeof v === 'string') { + return clipBlock(v) + } + + if (typeof v === 'number' || typeof v === 'boolean') { + return String(v) + } + + if (v == null) { + return '' + } + + if (Array.isArray(v)) { + return formatArraySummary(v, depth + 1) + } + + if (isRecord(v)) { + return formatRecordSummary(v, depth + 1) + } + + return clipInline(String(v)) +} + +function unwrapPayload(value: unknown): unknown { + let cur: unknown = norm(value) + + for (let i = 0; i < 4; i += 1) { + if (!isRecord(cur)) { + return cur + } + + const record = cur + const key = WRAPPER_KEYS.find(k => record[k] != null) + + if (!key) { + return record + } + + cur = norm(record[key]) + } + + return cur +} + +function hasMeaningfulErrorValue(value: unknown): boolean { + const v = norm(value) + + if (v == null) { + return false + } + + if (typeof v === 'string') { + return !NON_ERROR_TEXT.has(v.trim().toLowerCase()) + } + + if (typeof v === 'boolean') { + return v + } + + if (typeof v === 'number') { + return v !== 0 + } + + if (Array.isArray(v)) { + return v.some(hasMeaningfulErrorValue) + } + + if (isRecord(v)) { + return Object.keys(v).length > 0 + } + + return true +} + +function hasErrorSignal(record: Json): boolean { + const status = typeof record.status === 'string' ? record.status : '' + + return ( + record.success === false || + record.ok === false || + /\b(error|failed|failure|fatal|exception)\b/i.test(status) || + ERROR_KEYS.some(k => hasMeaningfulErrorValue(record[k])) + ) +} + +function valueErrorText(value: unknown): string { + const v = norm(value) + + if (typeof v === 'string') { + return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : '' + } + + if (Array.isArray(v)) { + return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12) + } + + if (isRecord(v)) { + const direct = firstString(v, ERROR_MSG_KEYS) + + if (direct) { + return clipBlock(direct, 700, 12) + } + } + + return '' +} + +function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string { + if (depth > 5) { + return '' + } + + const v = norm(value) + + if (!v || typeof v !== 'object' || seen.has(v)) { + return '' + } + + seen.add(v) + + if (Array.isArray(v)) { + for (const item of v) { + const nested = findNestedError(item, depth + 1, seen) + + if (nested) { + return nested + } + } + + return '' + } + + const record = v as Json + + for (const k of ERROR_KEYS) { + if (!hasMeaningfulErrorValue(record[k])) { + continue + } + + const text = valueErrorText(record[k]) + + if (text) { + return text + } + } + + if (hasErrorSignal(record)) { + const direct = firstString(record, ERROR_MSG_KEYS) + + if (direct) { + return clipBlock(direct, 700, 12) + } + } + + for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) { + const nested = findNestedError(record[k], depth + 1, seen) + + if (nested) { + return nested + } + } + + return '' +} + +export function formatToolResultSummary(value: unknown): string { + return formatSummaryValue(unwrapPayload(value), 0) || formatSummaryValue(value, 0) +} + +export function extractToolErrorMessage(value: unknown): string { + return findNestedError(value, 0, new Set()) +} diff --git a/apps/desktop/src/lib/use-enter-animation.ts b/apps/desktop/src/lib/use-enter-animation.ts new file mode 100644 index 000000000..c95878f41 --- /dev/null +++ b/apps/desktop/src/lib/use-enter-animation.ts @@ -0,0 +1,100 @@ +import { useCallback, useRef } from 'react' + +/** + * One-shot enter animation via the Web Animations API. + * + * Returns a callback ref. The animation fires exactly once when the element + * first attaches to the DOM and never replays for an already-mounted node — + * this is deliberate. CSS-transition + `@starting-style` is fragile here + * because: + * - Streaming deltas constantly invalidate ancestor state, which can + * re-trigger transitions on unrelated descendants. + * - `@starting-style` only covers DOM insertion / first-match, but any + * style restart during the message lifecycle replays the transition. + * - Some Chromium versions reset transitions when an attribute on an + * ancestor toggles, even if the descendant's properties never change. + * + * `el.animate(...)` runs against the element directly and is independent of + * CSS rule churn — it plays once, finishes, and is done. If the element + * unmounts and re-mounts, the callback ref runs again and replays it + * (correct behaviour). + * + * `enabled` is captured at mount-time only — flipping it later doesn't + * suddenly play the animation on existing nodes. + */ +const playedAnimationKeys = new Set<string>() +const playedAnimationOrder: string[] = [] +const MAX_TRACKED_KEYS = 2048 + +function hasPlayedAnimation(key: string): boolean { + return playedAnimationKeys.has(key) +} + +function rememberPlayedAnimation(key: string): void { + if (playedAnimationKeys.has(key)) { + return + } + + playedAnimationKeys.add(key) + playedAnimationOrder.push(key) + + if (playedAnimationOrder.length > MAX_TRACKED_KEYS) { + const evicted = playedAnimationOrder.shift() + + if (evicted) { + playedAnimationKeys.delete(evicted) + } + } +} + +function scheduleMicrotask(cb: () => void): void { + if (typeof queueMicrotask === 'function') { + queueMicrotask(cb) + + return + } + + void Promise.resolve().then(cb) +} + +export function useEnterAnimation(enabled: boolean, animationKey?: string): (el: HTMLElement | null) => void { + const enabledRef = useRef(enabled) + const keyRef = useRef(animationKey) + + enabledRef.current = enabled + keyRef.current = animationKey + + return useCallback((el: HTMLElement | null) => { + if (!el || !enabledRef.current || typeof window === 'undefined') { + return + } + + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + return + } + + const key = keyRef.current + + if (key && hasPlayedAnimation(key)) { + return + } + + el.animate( + [ + { opacity: 0, transform: 'translateY(0.5rem)' }, + { opacity: 1, transform: 'translateY(0)' } + ], + { duration: 220, easing: 'linear', fill: 'both' } + ) + + if (key) { + // In React StrictMode the first mount can be immediately torn down. + // Only persist "played" once the element survives to the microtask tick. + scheduleMicrotask(() => { + if (el.isConnected) { + rememberPlayedAnimation(key) + } + }) + } + }, []) +} diff --git a/apps/desktop/src/lib/utils.ts b/apps/desktop/src/lib/utils.ts new file mode 100644 index 000000000..d32b0fe65 --- /dev/null +++ b/apps/desktop/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts new file mode 100644 index 000000000..1554ed8a3 --- /dev/null +++ b/apps/desktop/src/lib/voice-playback.ts @@ -0,0 +1,128 @@ +import { speakText } from '@/hermes' +import { + $voicePlayback, + setVoicePlaybackState, + type VoicePlaybackSource, + type VoicePlaybackState +} from '@/store/voice-playback' + +import { sanitizeTextForSpeech } from './speech-text' + +let currentAudio: HTMLAudioElement | null = null +let currentStop: (() => void) | null = null +let sequence = 0 + +function currentState( + status: VoicePlaybackState['status'], + options?: VoicePlaybackOptions, + audioElement: HTMLAudioElement | null = null +): VoicePlaybackState { + return { + audioElement, + messageId: options?.messageId ?? null, + sequence, + source: options?.source ?? null, + status + } +} + +export interface VoicePlaybackOptions { + messageId?: string | null + source: VoicePlaybackSource +} + +export function stopVoicePlayback() { + sequence += 1 + currentStop?.() + currentStop = null + + if (currentAudio) { + currentAudio.pause() + currentAudio.src = '' + currentAudio.load() + currentAudio = null + } + + setVoicePlaybackState({ + audioElement: null, + messageId: null, + sequence, + source: null, + status: 'idle' + }) +} + +export async function playSpeechText(text: string, options: VoicePlaybackOptions): Promise<boolean> { + stopVoicePlayback() + + const speakableText = sanitizeTextForSpeech(text) + + if (!speakableText) { + return false + } + + const ownSequence = sequence + const isCurrent = () => ownSequence === sequence + + setVoicePlaybackState(currentState('preparing', options)) + + try { + const response = await speakText(speakableText) + + if (!isCurrent()) { + return false + } + + const audio = new Audio(response.data_url) + currentAudio = audio + setVoicePlaybackState(currentState('speaking', options, audio)) + + await new Promise<void>((resolve, reject) => { + const cleanup = () => { + audio.removeEventListener('ended', onEnded) + audio.removeEventListener('error', onError) + currentStop = null + } + + const onEnded = () => { + cleanup() + resolve() + } + + const onError = () => { + cleanup() + reject(new Error('Playback failed')) + } + + currentStop = () => { + cleanup() + resolve() + } + + audio.addEventListener('ended', onEnded, { once: true }) + audio.addEventListener('error', onError, { once: true }) + void audio.play().catch(reject) + }) + + if (!isCurrent()) { + return false + } + + currentAudio = null + setVoicePlaybackState(currentState('idle')) + + return true + } catch (error) { + if (isCurrent()) { + currentStop = null + currentAudio = null + setVoicePlaybackState(currentState('idle')) + } + + throw error + } +} + +export function isVoicePlaybackActive() { + return $voicePlayback.get().status !== 'idle' +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 000000000..f203e42d7 --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,45 @@ +import './styles.css' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { HashRouter } from 'react-router-dom' + +import App from './app' +import { HapticsProvider } from './components/haptics-provider' +import { installClipboardShim } from './lib/clipboard' +import { ThemeProvider } from './themes/context' + +installClipboardShim() + +// Dev-only: install __PERF_DRIVE__ + __PERF_PROBE__ on window so the +// scripts/ harnesses can drive a synthetic stream + record render cost. +// Tree-shaken out of production builds. (Uses MODE rather than DEV because +// our Vite setup currently bundles with PROD=true even in `vite dev`; see +// scripts/dev-no-hmr.mjs for the surrounding workarounds.) +if (import.meta.env.MODE !== 'production') { + import('./app/chat/perf-probe') +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 60_000 + } + } +}) + +createRoot(document.getElementById('root')!).render( + <StrictMode> + <QueryClientProvider client={queryClient}> + <ThemeProvider> + <HapticsProvider> + <HashRouter> + <App /> + </HashRouter> + </HapticsProvider> + </ThemeProvider> + </QueryClientProvider> + </StrictMode> +) diff --git a/apps/desktop/src/store/activity.ts b/apps/desktop/src/store/activity.ts new file mode 100644 index 000000000..f8a4ada42 --- /dev/null +++ b/apps/desktop/src/store/activity.ts @@ -0,0 +1,100 @@ +import { atom } from 'nanostores' + +import { sessionTitle } from '@/lib/chat-runtime' +import type { PreviewServerRestart } from '@/store/preview' +import type { ActionStatusResponse, SessionInfo } from '@/types/hermes' + +const HISTORY_LIMIT = 8 +const COMPLETED_TTL_MS = 5 * 60 * 1000 + +export type RailTaskStatus = 'error' | 'running' | 'success' + +export interface RailTask { + id: string + label: string + detail: string + status: RailTaskStatus + updatedAt: number +} + +export interface DesktopActionTask { + status: ActionStatusResponse + updatedAt: number +} + +export const $desktopActionTasks = atom<Record<string, DesktopActionTask>>({}) + +export function upsertDesktopActionTask(status: ActionStatusResponse): void { + $desktopActionTasks.set(prune({ ...$desktopActionTasks.get(), [status.name]: { status, updatedAt: Date.now() } })) +} + +export function buildRailTasks( + workingSessionIds: readonly string[], + sessions: readonly SessionInfo[], + previewRestart: PreviewServerRestart | null, + actionTasks: Record<string, DesktopActionTask> +): RailTask[] { + const sessionsById = new Map(sessions.map(session => [session.id, session])) + + const sessionTasks: RailTask[] = workingSessionIds.map((id, index) => { + const session = sessionsById.get(id) + + return { + id: `session:${id}`, + label: session ? sessionTitle(session) : 'Session task', + detail: 'Agent task running', + status: 'running', + updatedAt: session?.last_active || Date.now() - index + } + }) + + const previewTasks: RailTask[] = previewRestart + ? [ + { + id: `preview:${previewRestart.taskId}`, + label: 'Preview restart', + detail: previewRestart.message || previewRestart.url, + status: + previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success', + updatedAt: Date.now() + } + ] + : [] + + const actions: RailTask[] = Object.values(actionTasks).map(({ status, updatedAt }) => ({ + id: `action:${status.name}`, + label: status.name, + detail: actionDetail(status), + status: actionStatus(status), + updatedAt + })) + + return [...sessionTasks, ...previewTasks, ...actions].sort((left, right) => right.updatedAt - left.updatedAt) +} + +function actionStatus(status: ActionStatusResponse): RailTaskStatus { + if (status.running) { + return 'running' + } + + return status.exit_code === 0 ? 'success' : 'error' +} + +function actionDetail(status: ActionStatusResponse): string { + if (status.running) { + return 'Running' + } + + return status.exit_code === 0 ? 'Completed' : `Failed (${status.exit_code ?? 'unknown'})` +} + +function prune(tasks: Record<string, DesktopActionTask>): Record<string, DesktopActionTask> { + const now = Date.now() + + return Object.fromEntries( + Object.entries(tasks) + .filter(([, task]) => task.status.running || now - task.updatedAt <= COMPLETED_TTL_MS) + .sort(([, left], [, right]) => right.updatedAt - left.updatedAt) + .slice(0, HISTORY_LIMIT) + ) +} diff --git a/apps/desktop/src/store/boot.ts b/apps/desktop/src/store/boot.ts new file mode 100644 index 000000000..dfbd6d5f3 --- /dev/null +++ b/apps/desktop/src/store/boot.ts @@ -0,0 +1,90 @@ +import { atom } from 'nanostores' + +import type { DesktopBootProgress } from '@/global' + +export interface DesktopBootState extends DesktopBootProgress { + visible: boolean +} + +const INITIAL_BOOT_STATE: DesktopBootState = { + error: null, + fakeMode: false, + message: 'Starting Hermes Desktop…', + phase: 'renderer.init', + progress: 2, + running: true, + timestamp: Date.now(), + visible: true +} + +export const $desktopBoot = atom<DesktopBootState>(INITIAL_BOOT_STATE) + +function clampProgress(value: number) { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(100, Math.round(value))) +} + +export function applyDesktopBootProgress(progress: DesktopBootProgress) { + const current = $desktopBoot.get() + const nextProgress = clampProgress(progress.progress) + const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress + + $desktopBoot.set({ + ...current, + ...progress, + error: progress.error ?? null, + progress: mergedProgress, + visible: progress.running || mergedProgress < 100 || Boolean(progress.error) + }) +} + +export function setDesktopBootStep(step: { + phase: string + message: string + progress: number + running?: boolean + fakeMode?: boolean + error?: string | null +}) { + const current = $desktopBoot.get() + applyDesktopBootProgress({ + error: step.error ?? null, + fakeMode: step.fakeMode ?? current.fakeMode, + message: step.message, + phase: step.phase, + progress: step.progress, + running: step.running ?? true, + timestamp: Date.now() + }) +} + +export function completeDesktopBoot(message = 'Hermes Desktop is ready') { + const current = $desktopBoot.get() + $desktopBoot.set({ + ...current, + error: null, + message, + phase: 'renderer.ready', + progress: 100, + running: false, + timestamp: Date.now(), + visible: false + }) +} + +export function failDesktopBoot(message: string) { + const current = $desktopBoot.get() + $desktopBoot.set({ + ...current, + error: message, + message: `Desktop boot failed: ${message}`, + phase: 'renderer.error', + progress: clampProgress(current.progress), + running: false, + timestamp: Date.now(), + visible: true + }) +} diff --git a/apps/desktop/src/store/clarify.ts b/apps/desktop/src/store/clarify.ts new file mode 100644 index 000000000..ce90f48d8 --- /dev/null +++ b/apps/desktop/src/store/clarify.ts @@ -0,0 +1,32 @@ +import { atom } from 'nanostores' + +export interface ClarifyRequest { + requestId: string + question: string + choices: string[] | null + sessionId: string | null +} + +// Holds the request_id (and metadata) for the most recent in-flight +// clarify call. The inline ClarifyTool component (rendered inside the +// assistant message stream) reads this to know which request_id to send +// back over `clarify.respond`. +export const $clarifyRequest = atom<ClarifyRequest | null>(null) + +export function setClarifyRequest(request: ClarifyRequest): void { + $clarifyRequest.set(request) +} + +export function clearClarifyRequest(requestId?: string): void { + const current = $clarifyRequest.get() + + if (!current) { + return + } + + if (requestId && current.requestId !== requestId) { + return + } + + $clarifyRequest.set(null) +} diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts new file mode 100644 index 000000000..9f15232ae --- /dev/null +++ b/apps/desktop/src/store/composer-queue.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import type { ComposerAttachment } from './composer' +import { + $queuedPromptsBySession, + clearQueuedPrompts, + dequeueQueuedPrompt, + enqueueQueuedPrompt, + getQueuedPrompts, + removeQueuedPrompt, + updateQueuedPrompt, + updateQueuedPromptText +} from './composer-queue' + +const SESSION_KEY = 'session-abc' +const QUEUE_STORAGE_KEY = 'hermes.desktop.composerQueue.v1' + +function attachment(id: string, kind: ComposerAttachment['kind'] = 'file'): ComposerAttachment { + return { + id, + kind, + label: id, + refText: `@file:${id}` + } +} + +describe('composer queue store', () => { + beforeEach(() => { + window.localStorage.removeItem(QUEUE_STORAGE_KEY) + $queuedPromptsBySession.set({}) + }) + + it('queues prompts in FIFO order', () => { + enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' }) + enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' }) + + expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('first') + expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('second') + expect(dequeueQueuedPrompt(SESSION_KEY)).toBeNull() + }) + + it('clones attachments when queueing', () => { + const source = [attachment('a-1')] + const queued = enqueueQueuedPrompt(SESSION_KEY, { attachments: source, text: 'check clones' }) + + expect(queued).not.toBeNull() + expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).toEqual(source[0]) + expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).not.toBe(source[0]) + }) + + it('updates and removes queued entries by id', () => { + const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft one' }) + const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft two' }) + + expect(first).not.toBeNull() + expect(second).not.toBeNull() + + expect(updateQueuedPromptText(SESSION_KEY, first!.id, 'draft one edited')).toBe(true) + expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft one edited', 'draft two']) + + expect(removeQueuedPrompt(SESSION_KEY, first!.id)).toBe(true) + expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two']) + }) + + it('updates queued text and attachment snapshot', () => { + const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' }) + const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')] + + expect(first).not.toBeNull() + expect( + updateQueuedPrompt(SESSION_KEY, first!.id, { + attachments: editedAttachments, + text: 'edited text' + }) + ).toBe(true) + + const queue = getQueuedPrompts(SESSION_KEY) + expect(queue[0]?.text).toBe('edited text') + expect(queue[0]?.attachments).toEqual(editedAttachments) + expect(queue[0]?.attachments[0]).not.toBe(editedAttachments[0]) + }) + + it('clears queue state for a session', () => { + enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('img-1', 'image')], text: 'queued' }) + + clearQueuedPrompts(SESSION_KEY) + + expect(getQueuedPrompts(SESSION_KEY)).toEqual([]) + expect($queuedPromptsBySession.get()[SESSION_KEY]).toBeUndefined() + expect(window.localStorage.getItem(QUEUE_STORAGE_KEY)).toBeNull() + }) + + it('persists queue entries into local storage', () => { + enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'persist me' }) + + const raw = window.localStorage.getItem(QUEUE_STORAGE_KEY) + expect(raw).toBeTruthy() + + const parsed = JSON.parse(String(raw)) as Record<string, { text: string }[]> + expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me') + }) +}) diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts new file mode 100644 index 000000000..3f231fb7b --- /dev/null +++ b/apps/desktop/src/store/composer-queue.ts @@ -0,0 +1,190 @@ +import { atom } from 'nanostores' + +import type { ComposerAttachment } from './composer' + +export interface QueuedPromptEntry { + id: string + text: string + attachments: ComposerAttachment[] + queuedAt: number +} + +type QueueState = Record<string, QueuedPromptEntry[]> + +const STORAGE_KEY = 'hermes.desktop.composerQueue.v1' + +const load = (): QueueState => { + if (typeof window === 'undefined') { + return {} + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + const parsed = raw ? JSON.parse(raw) : null + + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as QueueState) : {} + } catch { + return {} + } +} + +const save = (state: QueueState) => { + if (typeof window === 'undefined') { + return + } + + try { + if (Object.keys(state).length === 0) { + window.localStorage.removeItem(STORAGE_KEY) + } else { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } + } catch { + // best-effort: storage may be unavailable, queue still works in-memory + } +} + +export const $queuedPromptsBySession = atom<QueueState>(load()) + +const writeSession = (sid: string, queue: QueuedPromptEntry[]) => { + const current = $queuedPromptsBySession.get() + const next = { ...current } + + if (queue.length === 0) { + delete next[sid] + } else { + next[sid] = queue + } + + $queuedPromptsBySession.set(next) + save(next) +} + +const sidOf = (key: string | null | undefined): null | string => { + const trimmed = key?.trim() + + return trimmed ? trimmed : null +} + +const queueFor = (sid: string) => $queuedPromptsBySession.get()[sid] ?? [] + +const nextId = () => `queued-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) + +export const getQueuedPrompts = (key: string | null | undefined): QueuedPromptEntry[] => { + const sid = sidOf(key) + + return sid ? queueFor(sid) : [] +} + +export const enqueueQueuedPrompt = ( + key: string | null | undefined, + payload: { text: string; attachments: ComposerAttachment[] } +): null | QueuedPromptEntry => { + const sid = sidOf(key) + + if (!sid) { + return null + } + + const entry: QueuedPromptEntry = { + id: nextId(), + text: payload.text, + attachments: cloneAttachments(payload.attachments), + queuedAt: Date.now() + } + + writeSession(sid, [...queueFor(sid), entry]) + + return entry +} + +export const dequeueQueuedPrompt = (key: string | null | undefined): null | QueuedPromptEntry => { + const sid = sidOf(key) + + if (!sid) { + return null + } + + const [head, ...rest] = queueFor(sid) + + if (!head) { + return null + } + + writeSession(sid, rest) + + return head +} + +export const removeQueuedPrompt = (key: string | null | undefined, id: string): boolean => { + const sid = sidOf(key) + + if (!sid) { + return false + } + + const queue = queueFor(sid) + const next = queue.filter(e => e.id !== id) + + if (next.length === queue.length) { + return false + } + + writeSession(sid, next) + + return true +} + +export const updateQueuedPrompt = ( + key: string | null | undefined, + id: string, + update: { text: string; attachments?: ComposerAttachment[] } +): boolean => { + const sid = sidOf(key) + + if (!sid) { + return false + } + + const queue = queueFor(sid) + let changed = false + + const next = queue.map(entry => { + if (entry.id !== id) { + return entry + } + + const attachments = update.attachments ? cloneAttachments(update.attachments) : entry.attachments + + if (entry.text === update.text && !update.attachments) { + return entry + } + + changed = true + + return { ...entry, text: update.text, attachments } + }) + + if (!changed) { + return false + } + + writeSession(sid, next) + + return true +} + +export const updateQueuedPromptText = (key: string | null | undefined, id: string, text: string): boolean => + updateQueuedPrompt(key, id, { text }) + +export const clearQueuedPrompts = (key: string | null | undefined) => { + const sid = sidOf(key) + + if (!sid || !(sid in $queuedPromptsBySession.get())) { + return + } + + writeSession(sid, []) +} diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts new file mode 100644 index 000000000..f986de980 --- /dev/null +++ b/apps/desktop/src/store/composer.ts @@ -0,0 +1,184 @@ +import { atom } from 'nanostores' + +import { triggerHaptic } from '@/lib/haptics' + +export interface ComposerAttachment { + id: string + kind: 'image' | 'file' | 'folder' | 'terminal' | 'url' + label: string + detail?: string + refText?: string + previewUrl?: string + path?: string + attachedSessionId?: string +} + +export const $composerDraft = atom('') +export const $composerAttachments = atom<ComposerAttachment[]>([]) +export const $composerTerminalSelections = atom<Record<string, string>>({}) + +export function setComposerDraft(value: string) { + $composerDraft.set(value) +} + +export function appendComposerDraft(value: string) { + const text = value.trim() + + if (!text) { + return + } + + const current = $composerDraft.get() + const separator = current && !current.endsWith('\n') ? '\n\n' : '' + + $composerDraft.set(`${current}${separator}${text}`) +} + +export function appendComposerInline(value: string) { + const text = value.trim() + + if (!text) { + return + } + + const current = $composerDraft.get().trimEnd() + const separator = current ? ' ' : '' + + $composerDraft.set(`${current}${separator}${text}`) +} + +export function clearComposerDraft() { + $composerDraft.set('') +} + +export function addComposerAttachment(attachment: ComposerAttachment) { + const previous = $composerAttachments.get() + const next = upsertAttachment(previous, attachment) + $composerAttachments.set(next) + + if (next.length > previous.length && attachment.kind !== 'url') { + triggerHaptic('selection') + } +} + +export function removeComposerAttachment(id: string): ComposerAttachment | null { + const current = $composerAttachments.get() + const removed = current.find(attachment => attachment.id === id) || null + $composerAttachments.set(current.filter(attachment => attachment.id !== id)) + + return removed +} + +export function clearComposerAttachments() { + $composerAttachments.set([]) +} + +const TERMINAL_REF_RE = /@terminal:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g + +function unquoteRefValue(raw: string) { + const head = raw[0] + const tail = raw[raw.length - 1] + const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'") + + return (quoted ? raw.slice(1, -1) : raw).replace(/[,.;!?]+$/, '').trim() +} + +function terminalLabelsFromDraft(draft: string) { + const labels: string[] = [] + const seen = new Set<string>() + + for (const match of draft.matchAll(TERMINAL_REF_RE)) { + const label = unquoteRefValue(match[1] || '') + + if (!label || seen.has(label)) { + continue + } + + seen.add(label) + labels.push(label) + } + + return labels +} + +export function setComposerTerminalSelection(label: string, text: string) { + const nextLabel = label.trim() + const nextText = text.trim() + + if (!nextLabel || !nextText) { + return + } + + const current = $composerTerminalSelections.get() + + if (current[nextLabel] === nextText) { + return + } + + $composerTerminalSelections.set({ + ...current, + [nextLabel]: nextText + }) +} + +export function reconcileComposerTerminalSelections(draft: string) { + const current = $composerTerminalSelections.get() + const labels = new Set(terminalLabelsFromDraft(draft)) + let changed = false + const next: Record<string, string> = {} + + for (const [label, text] of Object.entries(current)) { + if (!labels.has(label)) { + changed = true + + continue + } + + next[label] = text + } + + if (changed) { + $composerTerminalSelections.set(next) + } +} + +export function terminalContextBlocksFromDraft(draft: string) { + const labels = terminalLabelsFromDraft(draft) + + if (labels.length === 0) { + return [] + } + + const selections = $composerTerminalSelections.get() + + return labels.flatMap(label => { + const text = selections[label]?.trim() + + if (!text) { + return [] + } + + return `\`\`\`terminal\n${text}\n\`\`\`` + }) +} + +export function clearComposerTerminalSelections() { + if (Object.keys($composerTerminalSelections.get()).length === 0) { + return + } + + $composerTerminalSelections.set({}) +} + +function upsertAttachment(attachments: ComposerAttachment[], attachment: ComposerAttachment) { + const index = attachments.findIndex(item => item.id === attachment.id) + + if (index < 0) { + return [...attachments, attachment] + } + + const next = [...attachments] + next[index] = attachment + + return next +} diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts new file mode 100644 index 000000000..44cf0de0a --- /dev/null +++ b/apps/desktop/src/store/gateway.ts @@ -0,0 +1,16 @@ +import { atom } from 'nanostores' + +import type { HermesGateway } from '@/hermes' + +// The active gateway instance, exposed for inline message-stream components +// (e.g. inline ClarifyTool) that need to call gateway methods without having +// the instance threaded down through props from `ChatView`. +export const $gateway = atom<HermesGateway | null>(null) + +export function setGateway(gateway: HermesGateway | null): void { + if ($gateway.get() === gateway) { + return + } + + $gateway.set(gateway) +} diff --git a/apps/desktop/src/store/haptics.ts b/apps/desktop/src/store/haptics.ts new file mode 100644 index 000000000..8fc787351 --- /dev/null +++ b/apps/desktop/src/store/haptics.ts @@ -0,0 +1,17 @@ +import { atom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +const HAPTICS_MUTED_STORAGE_KEY = 'hermes.desktop.hapticsMuted' + +export const $hapticsMuted = atom(storedBoolean(HAPTICS_MUTED_STORAGE_KEY, false)) + +$hapticsMuted.subscribe(muted => persistBoolean(HAPTICS_MUTED_STORAGE_KEY, muted)) + +export function setHapticsMuted(muted: boolean) { + $hapticsMuted.set(muted) +} + +export function toggleHapticsMuted() { + $hapticsMuted.set(!$hapticsMuted.get()) +} diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts new file mode 100644 index 000000000..a01e22961 --- /dev/null +++ b/apps/desktop/src/store/layout.ts @@ -0,0 +1,140 @@ +import { atom, computed, type ReadableAtom } from 'nanostores' + +import { + arraysEqual, + insertUniqueId, + persistBoolean, + persistStringArray, + storedBoolean, + storedStringArray +} from '@/lib/storage' + +import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes' + +export const SIDEBAR_DEFAULT_WIDTH = 237 +export const SIDEBAR_MAX_WIDTH = 360 +export const FILE_BROWSER_DEFAULT_WIDTH = '17rem' +export const FILE_BROWSER_MIN_WIDTH = '14rem' +export const FILE_BROWSER_MAX_WIDTH = '20rem' + +export const SIDEBAR_SESSIONS_PAGE_SIZE = 50 + +const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions' +const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace' + +export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar' +export const FILE_BROWSER_PANE_ID = 'file-browser' +export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview' + +export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}` + +ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true }) +ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false }) + +export const $sidebarOpen: ReadableAtom<boolean> = computed( + $paneStates, + states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true +) + +export const $fileBrowserOpen: ReadableAtom<boolean> = computed( + $paneStates, + states => states[FILE_BROWSER_PANE_ID]?.open ?? false +) + +export const $rightRailActiveTabId = atom<RightRailTabId>(RIGHT_RAIL_PREVIEW_TAB_ID) + +export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => { + const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride + + return typeof override === 'number' ? override : SIDEBAR_DEFAULT_WIDTH +}) + +export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY)) +export const $sidebarPinsOpen = atom(true) +export const $sidebarRecentsOpen = atom(true) +export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false)) +export const $isSidebarResizing = atom(false) +export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE) + +$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids])) +$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped)) + +export function setSidebarWidth(width: number) { + const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width)) + setPaneWidthOverride(CHAT_SIDEBAR_PANE_ID, bounded) +} + +export function setSidebarOpen(open: boolean) { + setPaneOpen(CHAT_SIDEBAR_PANE_ID, open) +} + +export function toggleSidebarOpen() { + togglePane(CHAT_SIDEBAR_PANE_ID) +} + +export function toggleFileBrowserOpen() { + togglePane(FILE_BROWSER_PANE_ID) +} + +export function selectRightRailTab(id: RightRailTabId) { + $rightRailActiveTabId.set(id) +} + +export function setSidebarPinsOpen(open: boolean) { + $sidebarPinsOpen.set(open) +} + +export function setSidebarRecentsOpen(open: boolean) { + $sidebarRecentsOpen.set(open) +} + +export function setSidebarAgentsGrouped(grouped: boolean) { + $sidebarAgentsGrouped.set(grouped) +} + +export function setSidebarResizing(resizing: boolean) { + $isSidebarResizing.set(resizing) +} + +export function pinSession(sessionId: string, index?: number) { + const prev = $pinnedSessionIds.get() + const next = insertUniqueId(prev, sessionId, index ?? prev.filter(id => id !== sessionId).length) + + if (!arraysEqual(prev, next)) { + $pinnedSessionIds.set(next) + } +} + +export function unpinSession(sessionId: string) { + const prev = $pinnedSessionIds.get() + const next = prev.filter(id => id !== sessionId) + + if (!arraysEqual(prev, next)) { + $pinnedSessionIds.set(next) + } +} + +export function reorderPinnedSession(sessionId: string, targetIndex: number) { + const prev = $pinnedSessionIds.get() + + if (!prev.includes(sessionId)) { + return + } + + const next = insertUniqueId(prev, sessionId, targetIndex) + + if (!arraysEqual(prev, next)) { + $pinnedSessionIds.set(next) + } +} + +export function bumpSessionsLimit(step: number = SIDEBAR_SESSIONS_PAGE_SIZE) { + const safeStep = Math.max(1, Math.floor(step)) + $sessionsLimit.set($sessionsLimit.get() + safeStep) +} + +export function resetSessionsLimit() { + if ($sessionsLimit.get() !== SIDEBAR_SESSIONS_PAGE_SIZE) { + $sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE) + } +} diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts new file mode 100644 index 000000000..2c091b6b9 --- /dev/null +++ b/apps/desktop/src/store/notifications.ts @@ -0,0 +1,161 @@ +import { atom } from 'nanostores' + +export type NotificationKind = 'error' | 'warning' | 'info' | 'success' + +export interface NotificationAction { + label: string + onClick: () => void +} + +export interface AppNotification { + id: string + kind: NotificationKind + title?: string + message: string + detail?: string + action?: NotificationAction + onDismiss?: () => void + createdAt: number +} + +interface NotificationInput { + id?: string + kind?: NotificationKind + title?: string + message: string + detail?: string + action?: NotificationAction + onDismiss?: () => void + durationMs?: number +} + +let notificationCounter = 0 +const timers = new Map<string, number>() + +export const $notifications = atom<AppNotification[]>([]) + +function defaultDuration(kind: NotificationKind) { + if (kind === 'error' || kind === 'warning') { + return 0 + } + + return 5_000 +} + +function cleanErrorText(value: string) { + return value.replace(/^Error:\s*/, '').trim() +} + +const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string) => string }[] = [ + { + test: msg => /incorrect api key provided/i.test(msg) || /['"]code['"]\s*:\s*['"]invalid_api_key['"]/i.test(msg), + summarize: msg => { + const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1] + + return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.` + } + }, + { + test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg), + summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.' + }, + { + test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg), + summarize: msg => + /ELEVENLABS_API_KEY not set/i.test(msg) + ? 'ElevenLabs STT needs ELEVENLABS_API_KEY.' + : 'ElevenLabs rejected the API key (401).' + }, + { + test: msg => /method not allowed/i.test(msg), + summarize: () => 'The desktop backend does not support that audio endpoint yet. Restart Hermes Desktop.' + }, + { + test: msg => /microphone permission/i.test(msg), + summarize: () => 'Microphone permission was denied.' + } +] + +function summarizeErrorMessage(message: string, fallback: string) { + const rule = ERROR_SUMMARIES.find(r => r.test(message)) + + if (rule) { + return rule.summarize(message) + } + + return message.length > 180 ? fallback : message || fallback +} + +function readableError(error: unknown, fallback: string): { message: string; detail?: string } { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback + const unwrapped = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw + const cleaned = cleanErrorText(unwrapped) + const detail = cleaned.match(/"detail"\s*:\s*"([^"]+)"/)?.[1] ?? cleaned + const summary = summarizeErrorMessage(detail, fallback) + + return { message: summary, detail: detail === summary ? undefined : detail } +} + +export function notify(input: NotificationInput): string { + const kind = input.kind ?? 'info' + const id = input.id ?? `${Date.now()}-${notificationCounter++}` + + const notification: AppNotification = { + id, + kind, + title: input.title, + message: input.message, + detail: input.detail, + action: input.action, + onDismiss: input.onDismiss, + createdAt: Date.now() + } + + window.clearTimeout(timers.get(id)) + timers.delete(id) + $notifications.set([notification, ...$notifications.get().filter(item => item.id !== id)].slice(0, 4)) + + const duration = input.durationMs ?? defaultDuration(kind) + + if (duration > 0) { + timers.set( + id, + window.setTimeout(() => dismissNotification(id), duration) + ) + } + + return id +} + +export function notifyError(error: unknown, fallback: string): string { + const readable = readableError(error, fallback) + + return notify({ + kind: 'error', + title: fallback, + message: readable.message, + detail: readable.detail + }) +} + +export function dismissNotification(id: string) { + window.clearTimeout(timers.get(id)) + timers.delete(id) + const dismissed = $notifications.get().find(item => item.id === id) + $notifications.set($notifications.get().filter(item => item.id !== id)) + dismissed?.onDismiss?.() +} + +export function clearNotifications() { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + + timers.clear() + const all = $notifications.get() + $notifications.set([]) + + for (const item of all) { + item.onDismiss?.() + } +} diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts new file mode 100644 index 000000000..17c15930c --- /dev/null +++ b/apps/desktop/src/store/onboarding.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { OAuthProvider } from '@/types/hermes' + +import { + $desktopOnboarding, + type DesktopOnboardingState, + type OnboardingContext, + refreshOnboarding, + requestDesktopOnboarding +} from './onboarding' + +function provider(id: string, name = id): OAuthProvider { + return { + cli_command: `hermes login ${id}`, + docs_url: `https://example.com/${id}`, + flow: 'pkce', + id, + name, + status: { logged_in: false } + } +} + +function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnboardingState { + return { + configured: false, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false, + manual: false, + ...overrides + } +} + +function installApiMock(api: (request: { path: string }) => Promise<unknown>) { + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api } + }) +} + +function runtimeMismatchGateway(): OnboardingContext['requestGateway'] { + return async method => { + if (method === 'setup.status') { + return { provider_configured: true } as never + } + + if (method === 'setup.runtime_check') { + return { error: 'Selected runtime is not available.', ok: false } as never + } + + throw new Error(`unexpected gateway method: ${method}`) + } +} + +function onboardingContext(requestGateway: OnboardingContext['requestGateway']): OnboardingContext { + return { requestGateway } +} + +describe('refreshOnboarding', () => { + beforeEach(() => { + window.localStorage.clear() + $desktopOnboarding.set(baseState()) + }) + + afterEach(() => { + window.localStorage.clear() + $desktopOnboarding.set(baseState()) + vi.restoreAllMocks() + }) + + it('refreshes OAuth providers again when onboarding was explicitly requested', async () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path === '/api/providers/oauth') { + return { providers: [provider('fresh')] } + } + + throw new Error(`unexpected api path: ${path}`) + }) + + installApiMock(api) + $desktopOnboarding.set(baseState({ providers: [provider('cached')] })) + requestDesktopOnboarding('Need provider setup') + + const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway())) + + expect(ready).toBe(false) + expect(api).toHaveBeenCalledTimes(1) + expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['fresh']) + expect($desktopOnboarding.get().reason).toContain('Selected runtime is not available.') + expect($desktopOnboarding.get().reason).toContain('setup.status reports configured credentials') + }) + + it('keeps cached providers when onboarding was not re-requested', async () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path === '/api/providers/oauth') { + return { providers: [provider('fresh')] } + } + + throw new Error(`unexpected api path: ${path}`) + }) + + installApiMock(api) + $desktopOnboarding.set(baseState({ providers: [provider('cached')] })) + + const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway())) + + expect(ready).toBe(false) + expect(api).not.toHaveBeenCalled() + expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['cached']) + }) + + it('deduplicates concurrent provider refresh calls', async () => { + let resolveProviders!: (value: { providers: OAuthProvider[] }) => void + + const providersPromise = new Promise<{ providers: OAuthProvider[] }>(resolve => { + resolveProviders = value => { + resolve(value) + } + }) + + const api = vi.fn(async ({ path }: { path: string }) => { + if (path === '/api/providers/oauth') { + return providersPromise + } + + throw new Error(`unexpected api path: ${path}`) + }) + + installApiMock(api) + $desktopOnboarding.set(baseState({ requested: true })) + + const first = refreshOnboarding(onboardingContext(runtimeMismatchGateway())) + const second = refreshOnboarding(onboardingContext(runtimeMismatchGateway())) + + await vi.waitFor(() => expect(api).toHaveBeenCalledTimes(1)) + + resolveProviders({ providers: [provider('shared')] }) + await Promise.all([first, second]) + + expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['shared']) + }) +}) diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts new file mode 100644 index 000000000..90956f660 --- /dev/null +++ b/apps/desktop/src/store/onboarding.ts @@ -0,0 +1,675 @@ +import { atom } from 'nanostores' + +import { + cancelOAuthSession, + getGlobalModelOptions, + getRecommendedDefaultModel, + listOAuthProviders, + pollOAuthSession, + setEnvVar, + setModelAssignment, + startOAuthLogin, + submitOAuthCode, + validateProviderCredential +} from '@/hermes' +import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' +import { notify, notifyError } from '@/store/notifications' +import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes' + +type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }> +type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }> + +export type OnboardingMode = 'apikey' | 'oauth' + +export type OnboardingFlow = + | { status: 'idle' } + | { provider: OAuthProvider; status: 'starting' } + | { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' } + | { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' } + | { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' } + | { copied: boolean; provider: OAuthProvider; status: 'external_pending' } + | { provider: OAuthProvider; status: 'success' } + | { + // After successful credential acquisition, before completing + // onboarding: show the user which model they're getting and let + // them change it. providerSlug is the model.options slug for the + // just-authenticated provider (used to persist the chosen model + // via /api/model/set). The change-model UI uses the existing + // ModelPickerDialog, which fetches its own model list from + // /api/model/options — no need to cache the list here. + currentModel: string + label: string + providerSlug: string + saving: boolean + status: 'confirming_model' + } + | { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' } + +export interface DesktopOnboardingState { + /** null until the first runtime check resolves. Seeded from localStorage so + * returning users skip the boot overlay entirely instead of flashing it + * every reload. */ + configured: boolean | null + flow: OnboardingFlow + mode: OnboardingMode + providers: null | OAuthProvider[] + reason: null | string + requested: boolean + /** True when the user explicitly opened the provider selector to add / + * switch providers from an already-configured app (e.g. via the model + * picker's "Add provider" button). Forces the overlay to show the picker + * even when configured === true, and adds a close affordance. */ + manual: boolean +} + +export interface OnboardingContext { + onCompleted?: () => void + requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> +} + +const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1' +const POLL_MS = 2000 +const COPY_FLASH_MS = 1500 +const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.' + +function readCachedConfigured(): boolean | null { + if (typeof window === 'undefined') { + return null + } + + try { + return window.localStorage.getItem(CONFIGURED_CACHE_KEY) === '1' ? true : null + } catch { + return null + } +} + +function writeCachedConfigured(value: boolean) { + if (typeof window === 'undefined') { + return + } + + try { + if (value) { + window.localStorage.setItem(CONFIGURED_CACHE_KEY, '1') + } else { + window.localStorage.removeItem(CONFIGURED_CACHE_KEY) + } + } catch { + // localStorage unavailable — degrade silently. + } +} + +const INITIAL: DesktopOnboardingState = { + configured: readCachedConfigured(), + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false, + manual: false +} + +export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL) + +let pollTimer: number | null = null +let providersRefreshPromise: null | Promise<void> = null + +const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +const patch = (update: Partial<DesktopOnboardingState>) => + $desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update }) + +const setFlow = (flow: OnboardingFlow) => patch({ flow }) + +const sessionIdFor = (flow: OnboardingFlow) => ('start' in flow && flow.start ? flow.start.session_id : undefined) + +function clearPoll() { + if (pollTimer !== null) { + window.clearInterval(pollTimer) + pollTimer = null + } +} + +async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessResult> { + return evaluateRuntimeReadiness(ctx.requestGateway, { + defaultReason: DEFAULT_ONBOARDING_REASON, + unknownReady: false + }) +} + +function notifyReady(provider: string) { + notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` }) +} + +// Human-friendly labels for tools auto-routed through the Nous Tool Gateway, +// mirroring hermes_cli/nous_subscription._GATEWAY_TOOL_LABELS so the GUI and +// CLI describe the same thing. +const GATEWAY_TOOL_LABELS: Record<string, string> = { + browser: 'browser automation', + image_gen: 'image generation', + tts: 'text-to-speech', + video_gen: 'video generation', + web: 'web search & extract' +} + +// When switching to Nous auto-routes unconfigured tools through the Tool +// Gateway, tell the user which ones — same information the CLI prints. Silent +// when nothing changed (subscriber already configured, has own keys, etc.). +function notifyGatewayTools(tools: string[] | undefined) { + if (!tools || tools.length === 0) { + return + } + + const labels = tools.map(t => GATEWAY_TOOL_LABELS[t] ?? t) + const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}` + + notify({ + durationMs: 8000, + kind: 'info', + message: `${list} now run through your Nous subscription — no separate API keys needed.`, + title: 'Tool Gateway enabled' + }) +} + +// After credentials are persisted, ask the backend which provider+models +// are now authenticated. Pick the first curated model for the matching +// provider as a sensible default, persist it via /api/model/set, and +// transition to the model-confirmation step. If anything goes wrong +// fetching options (no providers returned, network error), the caller +// falls through to completing onboarding without showing the confirm +// card — the user gets the undefined-model auto-selection behaviour +// we had before, which works but is surprising. The confirm step is +// opportunistic polish, not a hard requirement for onboarding. +async function fetchProviderDefaultModel( + preferredSlugs: string[] +): Promise<null | { providerSlug: string; defaultModel: string }> { + let options + + try { + options = await getGlobalModelOptions() + } catch { + return null + } + + const providers = options?.providers ?? [] + + if (providers.length === 0) { + return null + } + + // Try each preferred slug (lowercased), fall back to the first provider + // returned (model.options orders by recency / authenticated state, so + // the just-authenticated provider is usually first anyway). + const lower = preferredSlugs.map(s => s.toLowerCase()) + + const matched = + providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0] + + const models = matched.models ?? [] + + if (models.length === 0) { + return null + } + + // Prefer the backend's recommended default — it mirrors the curation + // `hermes model` does (for Nous it honors the user's free/paid tier, so a + // free user gets a free model rather than a paid default like opus). Fall + // back to the first curated model if the endpoint can't resolve one. + let defaultModel = String(models[0]) + try { + const recommended = await getRecommendedDefaultModel(String(matched.slug)) + if (recommended.model && models.map(String).includes(recommended.model)) { + defaultModel = recommended.model + } else if (recommended.model) { + // Recommended model isn't in the curated options list (e.g. a Portal + // free-recommendation the picker list didn't include); trust it anyway. + defaultModel = recommended.model + } + } catch { + // Endpoint unavailable — keep models[0]. Non-fatal: the confirm card still + // shows and the user can change it. + } + + return { + providerSlug: String(matched.slug), + defaultModel + } +} + +// After OAuth/API-key success: reload the backend env, verify runtime, +// then either show the model-confirm step or fall straight through to +// completion if we can't determine a default. +// +// onFail receives the runtime-readiness `reason` from checkRuntime so +// the caller can fold it into a user-facing error — same contract as +// reloadAndConnect used to have (which this replaces). +async function completeWithModelConfirm( + ctx: OnboardingContext, + providerLabel: string, + preferredSlugs: string[], + onFail: (reason: null | string) => void +) { + await ctx.requestGateway('reload.env').catch(() => undefined) + const runtime = await checkRuntime(ctx) + + if (!runtime.ready) { + onFail(runtime.reason) + + return + } + + const defaults = await fetchProviderDefaultModel(preferredSlugs) + + if (!defaults) { + // Couldn't get a sensible default — proceed without confirm step. + notifyReady(providerLabel) + completeDesktopOnboarding() + ctx.onCompleted?.() + + return + } + + // Persist the default model BEFORE showing the confirm card so that: + // (1) "current default: X" shown in the UI is what's actually written + // to config — no lying. + // (2) If the user clicks "Start chatting" without changing anything, + // no extra write is needed. + // (3) If they bail out (e.g., refresh the page), they still end up + // with a working config, not an empty-model fallback. + try { + const res = await setModelAssignment({ + scope: 'main', + provider: defaults.providerSlug, + model: defaults.defaultModel + }) + notifyGatewayTools(res.gateway_tools) + } catch { + // Persistence failed — still show the confirm card so the user can + // pick something explicitly. The backend will pick its own default + // at chat time if we end up never persisting. + } + + setFlow({ + status: 'confirming_model', + providerSlug: defaults.providerSlug, + currentModel: defaults.defaultModel, + label: providerLabel, + saving: false + }) +} + +function providerResolutionFailure(reason: null | string) { + const detail = reason?.trim() + + return detail + ? `Connected, but Hermes still cannot resolve a usable provider. ${detail}` + : 'Connected, but Hermes still cannot resolve a usable provider.' +} + +async function refreshProviders() { + if (providersRefreshPromise) { + await providersRefreshPromise + + return + } + + providersRefreshPromise = (async () => { + try { + const { providers } = await listOAuthProviders() + patch({ mode: providers.length > 0 ? 'oauth' : 'apikey', providers }) + } catch { + patch({ mode: 'apikey', providers: [] }) + } finally { + providersRefreshPromise = null + } + })() + + await providersRefreshPromise +} + +export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) { + patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true }) +} + +// Open the onboarding provider selector on demand from an already-configured +// app — e.g. the model picker's "Add provider" button. Reuses the entire +// onboarding flow (OAuth rows, API-key form, model-confirm) instead of +// duplicating provider UI. Sets manual=true so the overlay shows the picker +// even though configured===true, and refreshes the provider list. +export function startManualOnboarding(reason = 'Add or switch inference provider.') { + patch({ + manual: true, + requested: true, + reason: reason.trim() || DEFAULT_ONBOARDING_REASON, + flow: { status: 'idle' } + }) + void refreshProviders() +} + +// Dismiss a manually-opened provider selector without touching the existing +// (working) configuration. Only valid in the manual path — the unconfigured +// first-run flow has no close affordance because the app can't run yet. +export function closeManualOnboarding() { + patch({ manual: false, requested: false, flow: { status: 'idle' } }) +} + +export function completeDesktopOnboarding() { + clearPoll() + writeCachedConfigured(true) + $desktopOnboarding.set({ + configured: true, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false, + manual: false + }) +} + +export function setOnboardingMode(mode: OnboardingMode) { + patch({ mode }) +} + +export async function refreshOnboarding(ctx: OnboardingContext) { + // Manual mode (user opened the selector from a working app): never + // auto-dismiss on runtime-ready — the whole point is to let them add / + // switch a provider while already configured. Just ensure the provider + // list is loaded and show the picker. + if ($desktopOnboarding.get().manual) { + await refreshProviders() + return false + } + + const runtime = await checkRuntime(ctx) + + if (runtime.ready) { + completeDesktopOnboarding() + ctx.onCompleted?.() + + return true + } + + const state = $desktopOnboarding.get() + const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON + + writeCachedConfigured(false) + patch({ configured: false, reason }) + + if (state.providers !== null && !state.requested) { + return false + } + + await refreshProviders() + + return false +} + +export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) { + clearPoll() + + if (provider.flow === 'external') { + setFlow({ status: 'external_pending', provider, copied: false }) + + return + } + + setFlow({ status: 'starting', provider }) + + try { + const start = await startOAuthLogin(provider.id) + await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url) + + if (start.flow === 'pkce') { + setFlow({ status: 'awaiting_user', provider, start, code: '' }) + + return + } + + setFlow({ status: 'polling', provider, start, copied: false }) + pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS) + } catch (error) { + setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` }) + } +} + +async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) { + try { + const { error_message, status } = await pollOAuthSession(provider.id, start.session_id) + + if (status === 'approved') { + clearPoll() + setFlow({ status: 'success', provider }) + await completeWithModelConfirm(ctx, provider.name, [provider.id], reason => + setFlow({ + status: 'error', + provider, + message: providerResolutionFailure(reason) + }) + ) + } else if (status !== 'pending') { + clearPoll() + setFlow({ status: 'error', provider, start, message: error_message || `Sign-in ${status}.` }) + } + } catch (error) { + clearPoll() + setFlow({ status: 'error', provider, start, message: `Polling failed: ${errMessage(error)}` }) + } +} + +export function setOnboardingCode(code: string) { + const { flow } = $desktopOnboarding.get() + + if (flow.status === 'awaiting_user') { + setFlow({ ...flow, code }) + } +} + +export async function submitOnboardingCode(ctx: OnboardingContext) { + const { flow } = $desktopOnboarding.get() + + if (flow.status !== 'awaiting_user' || !flow.code.trim()) { + return + } + + const { provider, start, code } = flow + setFlow({ status: 'submitting', provider, start }) + + try { + const resp = await submitOAuthCode(provider.id, start.session_id, code.trim()) + + if (resp.ok && resp.status === 'approved') { + setFlow({ status: 'success', provider }) + await completeWithModelConfirm(ctx, provider.name, [provider.id], reason => + setFlow({ + status: 'error', + provider, + message: providerResolutionFailure(reason) + }) + ) + } else { + setFlow({ status: 'error', provider, start, message: resp.message || 'Token exchange failed.' }) + } + } catch (error) { + setFlow({ status: 'error', provider, start, message: errMessage(error) }) + } +} + +export function cancelOnboardingFlow() { + clearPoll() + const sessionId = sessionIdFor($desktopOnboarding.get().flow) + + if (sessionId) { + cancelOAuthSession(sessionId).catch(() => undefined) + } + + setFlow({ status: 'idle' }) +} + +async function copyAndFlash(text: string, predicate: (flow: OnboardingFlow) => boolean) { + try { + await navigator.clipboard.writeText(text) + } catch { + return + } + + const { flow } = $desktopOnboarding.get() + + if (!predicate(flow) || !('copied' in flow)) { + return + } + + setFlow({ ...flow, copied: true }) + window.setTimeout(() => { + const current = $desktopOnboarding.get().flow + + if (predicate(current) && 'copied' in current) { + setFlow({ ...current, copied: false }) + } + }, COPY_FLASH_MS) +} + +export async function copyDeviceCode() { + const { flow } = $desktopOnboarding.get() + + if (flow.status !== 'polling') { + return + } + + const sid = flow.start.session_id + await copyAndFlash(flow.start.user_code, f => f.status === 'polling' && f.start.session_id === sid) +} + +export async function copyExternalCommand() { + const { flow } = $desktopOnboarding.get() + + if (flow.status !== 'external_pending') { + return + } + + const id = flow.provider.id + await copyAndFlash(flow.provider.cli_command, f => f.status === 'external_pending' && f.provider.id === id) +} + +export async function recheckExternalSignin(ctx: OnboardingContext) { + const { flow } = $desktopOnboarding.get() + + if (flow.status !== 'external_pending') { + return + } + + const { provider } = flow + await completeWithModelConfirm(ctx, provider.name, [provider.id], reason => + setFlow({ + status: 'error', + provider, + message: + reason?.trim() || + `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.` + }) + ) +} + +export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) { + const trimmed = value.trim() + + if (!trimmed) { + return { ok: false, message: 'Enter a value first.' } + } + + // Live-probe the credential BEFORE persisting so a mistyped key never lands + // in .env. A rejected key (reachable && !ok) hard-blocks; an unreachable + // probe (offline / provider down) falls through and saves with the usual + // runtime check, so we don't strand offline users. + try { + const probe = await validateProviderCredential(envKey, trimmed) + if (!probe.ok && probe.reachable) { + return { ok: false, message: probe.message || `That ${label} key was rejected.` } + } + } catch { + // Validation endpoint unavailable — don't block; fall through to save. + } + + try { + await setEnvVar(envKey, trimmed) + let stillFailing = false + let runtimeFailure: null | string = null + // For API-key flows we don't have a definitive provider id (the + // user picked which API key they're entering, but the corresponding + // backend slug — e.g. OPENROUTER_API_KEY → "openrouter" — is the + // env-key prefix stripped). Pass a couple of likely candidates; + // fetchProviderDefaultModel falls back to the first authenticated + // provider returned by /api/model/options if none match. + const slugCandidates = [envKey.replace(/_API_KEY$/, '').toLowerCase(), label.toLowerCase()] + await completeWithModelConfirm(ctx, label, slugCandidates, reason => { + stillFailing = true + runtimeFailure = reason + }) + + if (stillFailing) { + const failureDetail = (runtimeFailure ?? '').trim() + + return { + ok: false, + message: failureDetail || `Saved, but Hermes still cannot reach ${label}. Double-check the value.` + } + } + + return { ok: true } + } catch (error) { + notifyError(error, `Could not save ${label}`) + + return { ok: false, message: errMessage(error) } + } +} + +// User picked a different model from the dropdown on the confirm card. +// Persists immediately so the displayed value is always what's on disk. +export async function setOnboardingModel(model: string) { + const { flow } = $desktopOnboarding.get() + + if (flow.status !== 'confirming_model') { + return + } + + // Optimistic update so the dropdown feels instant; revert on failure. + const previous = flow.currentModel + setFlow({ ...flow, currentModel: model, saving: true }) + + try { + await setModelAssignment({ + scope: 'main', + provider: flow.providerSlug, + model + }) + const current = $desktopOnboarding.get().flow + + if (current.status === 'confirming_model') { + setFlow({ ...current, currentModel: model, saving: false }) + } + } catch (error) { + notifyError(error, 'Could not change model') + const current = $desktopOnboarding.get().flow + + if (current.status === 'confirming_model') { + setFlow({ ...current, currentModel: previous, saving: false }) + } + } +} + +// User clicked "Start chatting" on the confirm card. Finalizes onboarding +// — the model was already persisted by completeWithModelConfirm (or by +// setOnboardingModel if they changed it), so all that's left is to mark +// onboarding done and unblock the rest of the app. +export function confirmOnboardingModel(ctx: OnboardingContext) { + const { flow } = $desktopOnboarding.get() + + if (flow.status !== 'confirming_model') { + return + } + + notifyReady(flow.label) + completeDesktopOnboarding() + ctx.onCompleted?.() +} diff --git a/apps/desktop/src/store/panes.test.ts b/apps/desktop/src/store/panes.test.ts new file mode 100644 index 000000000..6986ae277 --- /dev/null +++ b/apps/desktop/src/store/panes.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + $paneOpen, + $paneStates, + $paneWidthOverride, + clearPaneWidthOverride, + ensurePaneRegistered, + getPaneStateSnapshot, + setPaneOpen, + setPaneWidthOverride, + togglePane +} from './panes' + +const STORAGE_KEY = 'hermes.desktop.paneStates.v1' + +describe('panes store', () => { + beforeEach(() => { + $paneStates.set({}) + window.localStorage.clear() + }) + + afterEach(() => { + $paneStates.set({}) + window.localStorage.clear() + }) + + describe('ensurePaneRegistered', () => { + it('adds a pane with defaults when missing', () => { + ensurePaneRegistered('files', { open: true }) + + expect(getPaneStateSnapshot('files')).toEqual({ open: true, widthOverride: undefined }) + }) + + it('is a no-op when the pane already exists', () => { + ensurePaneRegistered('files', { open: false }) + ensurePaneRegistered('files', { open: true }) + + expect(getPaneStateSnapshot('files')?.open).toBe(false) + }) + + it('preserves an existing widthOverride when re-registering', () => { + ensurePaneRegistered('files', { open: true }) + setPaneWidthOverride('files', 360) + ensurePaneRegistered('files', { open: false }) + + expect(getPaneStateSnapshot('files')?.widthOverride).toBe(360) + }) + }) + + describe('setPaneOpen / togglePane', () => { + it('updates the pane open flag', () => { + ensurePaneRegistered('files', { open: false }) + setPaneOpen('files', true) + + expect(getPaneStateSnapshot('files')?.open).toBe(true) + }) + + it('togglePane flips the current value', () => { + ensurePaneRegistered('files', { open: false }) + togglePane('files') + togglePane('files') + togglePane('files') + + expect(getPaneStateSnapshot('files')?.open).toBe(true) + }) + + it('togglePane on an unregistered id starts from false', () => { + togglePane('ephemeral') + + expect(getPaneStateSnapshot('ephemeral')?.open).toBe(true) + }) + + it('preserves widthOverride across open/close changes', () => { + ensurePaneRegistered('files', { open: true }) + setPaneWidthOverride('files', 280) + setPaneOpen('files', false) + setPaneOpen('files', true) + + expect(getPaneStateSnapshot('files')?.widthOverride).toBe(280) + }) + }) + + describe('width overrides', () => { + it('setPaneWidthOverride stores the px value', () => { + ensurePaneRegistered('files', { open: true }) + setPaneWidthOverride('files', 300) + + expect(getPaneStateSnapshot('files')?.widthOverride).toBe(300) + }) + + it('clearPaneWidthOverride removes the override', () => { + ensurePaneRegistered('files', { open: true }) + setPaneWidthOverride('files', 300) + clearPaneWidthOverride('files') + + expect(getPaneStateSnapshot('files')?.widthOverride).toBeUndefined() + }) + + it('width override is in-memory only — not persisted across reloads', () => { + ensurePaneRegistered('files', { open: true }) + setPaneWidthOverride('files', 300) + + const persisted = window.localStorage.getItem(STORAGE_KEY) + + expect(persisted).not.toBeNull() + expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } }) + }) + + it('open flag is persisted across changes', () => { + ensurePaneRegistered('files', { open: false }) + setPaneOpen('files', true) + + const persisted = window.localStorage.getItem(STORAGE_KEY) + + expect(persisted).not.toBeNull() + expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } }) + }) + }) + + describe('derived atoms', () => { + it('$paneOpen reflects the pane state', () => { + const open$ = $paneOpen('files') + expect(open$.get()).toBe(false) + + ensurePaneRegistered('files', { open: true }) + expect(open$.get()).toBe(true) + + setPaneOpen('files', false) + expect(open$.get()).toBe(false) + }) + + it('$paneWidthOverride reflects the width', () => { + const width$ = $paneWidthOverride('files') + expect(width$.get()).toBeUndefined() + + ensurePaneRegistered('files', { open: true }) + setPaneWidthOverride('files', 240) + expect(width$.get()).toBe(240) + }) + + it('$paneOpen returns the same atom instance for repeated calls', () => { + expect($paneOpen('files')).toBe($paneOpen('files')) + }) + }) +}) diff --git a/apps/desktop/src/store/panes.ts b/apps/desktop/src/store/panes.ts new file mode 100644 index 000000000..41e1effd5 --- /dev/null +++ b/apps/desktop/src/store/panes.ts @@ -0,0 +1,145 @@ +import { atom, computed, type ReadableAtom } from 'nanostores' + +export interface PaneStateSnapshot { + open: boolean + widthOverride?: number +} + +export interface PaneRegisterDefaults { + open: boolean + widthOverride?: number +} + +const STORAGE_KEY = 'hermes.desktop.paneStates.v1' + +function isSnapshot(value: unknown): value is PaneStateSnapshot { + if (!value || typeof value !== 'object') { + return false + } + + const r = value as Record<string, unknown> + + if (typeof r.open !== 'boolean') { + return false + } + + return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride)) +} + +function load(): Record<string, PaneStateSnapshot> { + if (typeof window === 'undefined') { + return {} + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + + if (raw) { + const parsed = JSON.parse(raw) as unknown + + if (parsed && typeof parsed === 'object') { + const out: Record<string, PaneStateSnapshot> = {} + + for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) { + if (isSnapshot(value)) { + out[id] = { open: value.open, widthOverride: value.widthOverride } + } + } + + return out + } + } + } catch { + // Treat unparseable persisted state as missing. + } + + return {} +} + +// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in. +function persist(states: Record<string, PaneStateSnapshot>) { + if (typeof window === 'undefined') { + return + } + + const minimal: Record<string, { open: boolean }> = {} + + for (const [id, s] of Object.entries(states)) { + minimal[id] = { open: s.open } + } + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal)) + } catch { + // Storage failures are nonfatal. + } +} + +export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load()) + +$paneStates.subscribe(persist) + +// Cached per-pane derived atoms keep useStore subscriptions referentially stable. +function memoized<T>( + cache: Map<string, ReadableAtom<T>>, + id: string, + selector: (s: PaneStateSnapshot | undefined) => T +) { + let cached = cache.get(id) + + if (!cached) { + cached = computed($paneStates, states => selector(states[id])) + cache.set(id, cached) + } + + return cached +} + +const openCache = new Map<string, ReadableAtom<boolean>>() +const stateCache = new Map<string, ReadableAtom<PaneStateSnapshot | undefined>>() +const widthCache = new Map<string, ReadableAtom<number | undefined>>() + +export const $paneOpen = (id: string) => memoized(openCache, id, s => s?.open ?? false) +export const $paneState = (id: string) => memoized(stateCache, id, s => s) +export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => s?.widthOverride) + +export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) { + const current = $paneStates.get() + + if (current[id] !== undefined) { + return + } + + $paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } }) +} + +export function setPaneOpen(id: string, open: boolean) { + const current = $paneStates.get() + const existing = current[id] + + if (existing?.open === open) { + return + } + + $paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } }) +} + +export function togglePane(id: string) { + const current = $paneStates.get() + const existing = current[id] + $paneStates.set({ ...current, [id]: { open: !(existing?.open ?? false), widthOverride: existing?.widthOverride } }) +} + +export function setPaneWidthOverride(id: string, width: number | undefined) { + const current = $paneStates.get() + const existing = current[id] ?? { open: false } + + if (existing.widthOverride === width) { + return + } + + $paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } }) +} + +export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined) +export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id] diff --git a/apps/desktop/src/store/preview.test.ts b/apps/desktop/src/store/preview.test.ts new file mode 100644 index 000000000..631cedc4d --- /dev/null +++ b/apps/desktop/src/store/preview.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout' +import { + $filePreviewTabs, + $filePreviewTarget, + $previewServerRestart, + $previewServerRestartStatus, + $previewTarget, + $sessionPreviewRegistry, + beginPreviewServerRestart, + clearSessionPreviewRegistry, + closeActiveRightRailTab, + dismissPreviewTarget, + getSessionPreviewRecord, + type PreviewTarget, + progressPreviewServerRestart, + setCurrentSessionPreviewTarget +} from './preview' +import { $activeSessionId, $selectedStoredSessionId } from './session' + +function previewTarget(source: string): PreviewTarget { + return { + kind: 'file', + label: source, + path: source, + previewKind: 'html', + source, + url: `file://${source}` + } +} + +function withRenderMode(target: PreviewTarget, renderMode: PreviewTarget['renderMode']): PreviewTarget { + return { ...target, renderMode } +} + +describe('preview store', () => { + beforeEach(() => { + $previewServerRestart.set(null) + $activeSessionId.set('session-1') + $selectedStoredSessionId.set(null) + window.localStorage.clear() + clearSessionPreviewRegistry() + }) + + afterEach(() => { + $previewServerRestart.set(null) + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + window.localStorage.clear() + clearSessionPreviewRegistry() + }) + + it('does not notify status subscribers for restart progress text', () => { + const statuses: string[] = [] + const unsubscribe = $previewServerRestartStatus.subscribe(status => statuses.push(status)) + + beginPreviewServerRestart('task-1', 'http://localhost:5174') + progressPreviewServerRestart('task-1', 'first line') + progressPreviewServerRestart('task-1', 'second line') + unsubscribe() + + expect(statuses).toEqual(['idle', 'running']) + }) + + it('persists registered previews and dismissal per session', () => { + const target = previewTarget('/work/demo.html') + + setCurrentSessionPreviewTarget(target, 'tool-result') + + expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview')) + expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview')) + expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html') + + dismissPreviewTarget() + + expect($previewTarget.get()).toBeNull() + expect(getSessionPreviewRecord('session-1')).toBeNull() + expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number)) + + setCurrentSessionPreviewTarget(target, 'tool-result') + + expect(getSessionPreviewRecord('session-1')?.dismissedAt).toBeUndefined() + }) + + it('replaces the session preview instead of keeping a back stack', () => { + const first = previewTarget('/work/first.html') + const second = previewTarget('/work/second.html') + + setCurrentSessionPreviewTarget(first, 'tool-result') + setCurrentSessionPreviewTarget(second, 'tool-result') + + expect($sessionPreviewRegistry.get()['session-1']).toHaveLength(1) + expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(second, 'preview')) + + dismissPreviewTarget() + + expect($previewTarget.get()).toBeNull() + expect(getSessionPreviewRecord('session-1')).toBeNull() + expect($sessionPreviewRegistry.get()['session-1']?.map(record => record.normalized.url)).toEqual([ + 'file:///work/second.html' + ]) + }) + + it('keeps file inspection separate from live preview', () => { + const target = previewTarget('/work/demo.html') + const preview = previewTarget('/work/live.html') + + setCurrentSessionPreviewTarget(preview, 'tool-result') + + setCurrentSessionPreviewTarget(target, 'manual') + + expect($filePreviewTarget.get()).toEqual(withRenderMode(target, 'source')) + expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview')) + expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(preview, 'preview')) + + closeActiveRightRailTab() + + expect($filePreviewTarget.get()).toBeNull() + expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview')) + }) + + it('keeps file tabs when a live preview opens', () => { + const file = previewTarget('/work/file.html') + const live = previewTarget('/work/live.html') + + setCurrentSessionPreviewTarget(file, 'manual') + setCurrentSessionPreviewTarget(live, 'tool-result') + + expect($filePreviewTabs.get().map(tab => tab.target)).toEqual([withRenderMode(file, 'source')]) + expect($filePreviewTarget.get()).toBeNull() + expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID) + expect($previewTarget.get()).toEqual(withRenderMode(live, 'preview')) + }) +}) diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts new file mode 100644 index 000000000..3fff6a240 --- /dev/null +++ b/apps/desktop/src/store/preview.ts @@ -0,0 +1,466 @@ +import { atom, computed } from 'nanostores' + +import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout' +import { $activeSessionId, $selectedStoredSessionId } from './session' + +export interface PreviewTarget { + binary?: boolean + byteSize?: number + kind: 'file' | 'url' + label: string + large?: boolean + language?: string + mimeType?: string + path?: string + previewKind?: 'binary' | 'html' | 'image' | 'text' + renderMode?: 'preview' | 'source' + source: string + url: string +} + +export interface PreviewServerRestart { + message?: string + status: 'complete' | 'error' | 'running' + taskId: string + url: string +} + +export type PreviewRecordSource = 'explicit-link' | 'file-browser' | 'manual' | 'tool-result' + +export interface SessionPreviewRecord { + autoOpen?: boolean + createdAt: number + dismissedAt?: number + id: string + normalized: PreviewTarget + sessionId: string + source: PreviewRecordSource + target: string +} + +type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]> + +export interface FilePreviewTab { + id: `file:${string}` + target: PreviewTarget +} + +const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1' +const MAX_RECORDS_PER_SESSION = 1 +const MAX_SESSIONS = 120 + +export const $previewTarget = atom<PreviewTarget | null>(null) +export const $filePreviewTabs = atom<FilePreviewTab[]>([]) +export const $filePreviewTarget = computed([$filePreviewTabs, $rightRailActiveTabId], (tabs, activeTabId) => { + if (!activeTabId.startsWith('file:')) { + return null + } + + return tabs.find(tab => tab.id === activeTabId)?.target ?? null +}) +export const $previewReloadRequest = atom(0) +export const $previewServerRestart = atom<PreviewServerRestart | null>(null) +export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle') +export const $sessionPreviewRegistry = atom<SessionPreviewRegistry>(loadSessionPreviewRegistry()) + +$sessionPreviewRegistry.subscribe(persistSessionPreviewRegistry) + +function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean { + if (a === b) { + return true + } + + if (!a || !b) { + return false + } + + return ( + a.kind === b.kind && + a.label === b.label && + a.renderMode === b.renderMode && + a.source === b.source && + a.url === b.url + ) +} + +export function setPreviewTarget(target: PreviewTarget | null) { + if (isSamePreviewTarget($previewTarget.get(), target)) { + if (target) { + selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID) + } + + return + } + + $previewTarget.set(target) + + if (target) { + selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID) + } +} + +export function filePreviewTabId(target: PreviewTarget): `file:${string}` { + return `file:${target.url}` +} + +function openFilePreviewTarget(target: PreviewTarget) { + const id = filePreviewTabId(target) + const current = $filePreviewTabs.get() + const index = current.findIndex(tab => tab.id === id) + const tab: FilePreviewTab = { id, target } + + $filePreviewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item))) + selectRightRailTab(id) +} + +// Manual/file-browser opens are "peeking at a file" → source view in the file +// pane. Tool/explicit-link opens are runnable artifacts → live preview pane. +function isFilePreviewSource(source: PreviewRecordSource): boolean { + return source === 'file-browser' || source === 'manual' +} + +function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget { + if (target.kind !== 'file' || target.previewKind !== 'html') { + return target + } + + return { ...target, renderMode: isFilePreviewSource(source) ? 'source' : 'preview' } +} + +function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean { + if (target.kind !== 'file' || !isFilePreviewSource(source)) { + return false + } + + openFilePreviewTarget(previewTargetForSource(target, source)) + + return true +} + +function isPreviewTarget(value: unknown): value is PreviewTarget { + if (!value || typeof value !== 'object') { + return false + } + + const r = value as Record<string, unknown> + + return ( + (r.kind === 'file' || r.kind === 'url') && + typeof r.label === 'string' && + typeof r.source === 'string' && + typeof r.url === 'string' + ) +} + +function isPreviewRecord(value: unknown): value is SessionPreviewRecord { + if (!value || typeof value !== 'object') { + return false + } + + const r = value as Record<string, unknown> + + return ( + typeof r.createdAt === 'number' && + typeof r.id === 'string' && + isPreviewTarget(r.normalized) && + typeof r.sessionId === 'string' && + ['explicit-link', 'file-browser', 'manual', 'tool-result'].includes(String(r.source)) && + typeof r.target === 'string' && + (r.dismissedAt === undefined || typeof r.dismissedAt === 'number') + ) +} + +function loadSessionPreviewRegistry(): SessionPreviewRegistry { + if (typeof window === 'undefined') { + return {} + } + + try { + const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY) + + if (!raw) { + return {} + } + + const parsed = JSON.parse(raw) as unknown + + if (!parsed || typeof parsed !== 'object') { + return {} + } + + const out: SessionPreviewRegistry = {} + + for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) { + if (!Array.isArray(records)) { + continue + } + + const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION) + + if (valid.length > 0) { + out[sessionId] = valid + } + } + + return pruneRegistry(out) + } catch { + return {} + } +} + +function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) { + if (typeof window === 'undefined') { + return + } + + try { + window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry))) + } catch { + // Session previews are a desktop convenience; storage failures are nonfatal. + } +} + +function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry { + const entries = Object.entries(registry) + .map( + ([sessionId, records]) => + [sessionId, [...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)] as const + ) + .filter(([, records]) => records.length > 0) + .sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0)) + .slice(0, MAX_SESSIONS) + + return Object.fromEntries(entries) +} + +function currentPreviewSessionId(): string { + return $selectedStoredSessionId.get() || $activeSessionId.get() || '' +} + +function recordId(sessionId: string, target: PreviewTarget): string { + return `${sessionId}:${target.url}` +} + +export function registerSessionPreview( + sessionId: string | null | undefined, + target: PreviewTarget, + source: PreviewRecordSource, + rawTarget = target.source +): SessionPreviewRecord | null { + const id = sessionId?.trim() + + if (!id) { + return null + } + + const current = $sessionPreviewRegistry.get() + const now = Date.now() + const records = current[id] ?? [] + const existing = records.find(record => record.normalized.url === target.url) + const normalized = previewTargetForSource(target, source) + + const nextRecord: SessionPreviewRecord = { + autoOpen: true, + createdAt: now, + id: existing?.id || recordId(id, target), + normalized, + sessionId: id, + source, + target: rawTarget || target.source + } + + $sessionPreviewRegistry.set( + pruneRegistry({ + ...current, + [id]: [nextRecord] + }) + ) + + return nextRecord +} + +export function setSessionPreviewTarget( + sessionId: string | null | undefined, + target: PreviewTarget, + source: PreviewRecordSource, + rawTarget = target.source +): SessionPreviewRecord | null { + if (tryOpenFilePreview(target, source)) { + return null + } + + const record = registerSessionPreview(sessionId, target, source, rawTarget) + + setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source)) + + return record +} + +export function setCurrentSessionPreviewTarget( + target: PreviewTarget, + source: PreviewRecordSource, + rawTarget = target.source +): SessionPreviewRecord | null { + return setSessionPreviewTarget(currentPreviewSessionId(), target, source, rawTarget) +} + +export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null { + const id = sessionId?.trim() + + if (!id) { + return null + } + + return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null +} + +export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) { + const id = sessionId?.trim() + + if (!id) { + return + } + + const current = $sessionPreviewRegistry.get() + const records = current[id] + + if (!records?.length) { + return + } + + const now = Date.now() + const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url + + if (!targetUrl) { + return + } + + // The preview rail is a single active file, not a back stack. Dismissing the + // current preview should leave the rail closed instead of revealing an older + // record for the same session. + const dismissedRecords = records.map(record => ({ + ...record, + autoOpen: false, + dismissedAt: now + })) + + $sessionPreviewRegistry.set({ + ...current, + [id]: dismissedRecords + }) +} + +/** User clicked the close X — clear the target and persist dismissal for the current session. */ +export function dismissPreviewTarget() { + const current = $previewTarget.get() + + if (current?.url) { + dismissSessionPreview(currentPreviewSessionId(), current.url) + } + + $previewTarget.set(null) + + if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) { + selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID) + } +} + +function closeFilePreviewTab(tabId: RightRailTabId) { + if (!tabId.startsWith('file:')) { + return + } + + const current = $filePreviewTabs.get() + const index = current.findIndex(tab => tab.id === tabId) + + if (index === -1) { + return + } + + const next = current.filter(tab => tab.id !== tabId) + + $filePreviewTabs.set(next) + + if ($rightRailActiveTabId.get() === tabId) { + selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID) + } +} + +export function closeRightRailTab(tabId: RightRailTabId) { + if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID) { + if ($previewTarget.get()) { + dismissPreviewTarget() + } + + return + } + + closeFilePreviewTab(tabId) +} + +export const closeActiveRightRailTab = () => closeRightRailTab($rightRailActiveTabId.get()) + +/** Dismisses the active preview + every file tab so the rail pane unmounts. */ +export function closeRightRail() { + if ($previewTarget.get()) { + dismissPreviewTarget() + } + + $filePreviewTabs.set([]) +} + +export function clearSessionPreviewRegistry() { + $sessionPreviewRegistry.set({}) + setPreviewTarget(null) + $filePreviewTabs.set([]) + selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID) +} + +export function requestPreviewReload() { + $previewReloadRequest.set($previewReloadRequest.get() + 1) +} + +export function beginPreviewServerRestart(taskId: string, url: string) { + $previewServerRestart.set({ status: 'running', taskId, url }) +} + +export function completePreviewServerRestart(taskId: string, text: string) { + const current = $previewServerRestart.get() + + if (current?.taskId !== taskId) { + return + } + + $previewServerRestart.set({ + ...current, + message: text, + status: text.trim().toLowerCase().startsWith('error:') ? 'error' : 'complete' + }) +} + +export function progressPreviewServerRestart(taskId: string, text: string) { + const current = $previewServerRestart.get() + + if (current?.taskId !== taskId || current.status !== 'running') { + return + } + + $previewServerRestart.set({ + ...current, + message: text + }) +} + +export function failPreviewServerRestart(taskId: string, message: string) { + const current = $previewServerRestart.get() + + if (current?.taskId !== taskId || current.status !== 'running') { + return + } + + $previewServerRestart.set({ + ...current, + message, + status: 'error' + }) +} diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts new file mode 100644 index 000000000..6cd26f6b9 --- /dev/null +++ b/apps/desktop/src/store/session.ts @@ -0,0 +1,96 @@ +import { atom } from 'nanostores' + +import type { ContextSuggestion } from '@/app/types' +import type { HermesConnection } from '@/global' +import type { ChatMessage } from '@/lib/chat-messages' +import type { SessionInfo, UsageStats } from '@/types/hermes' + +type Updater<T> = T | ((current: T) => T) + +interface AppAtom<T> { + get: () => T + set: (value: T) => void +} + +function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) { + store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next) +} + +export const $connection = atom<HermesConnection | null>(null) +export const $gatewayState = atom('idle') +export const $sessions = atom<SessionInfo[]>([]) +export const $sessionsTotal = atom<number>(0) +export const $sessionsLoading = atom(true) +export const $workingSessionIds = atom<string[]>([]) +export const $activeSessionId = atom<string | null>(null) +export const $selectedStoredSessionId = atom<string | null>(null) +export const $messages = atom<ChatMessage[]>([]) +export const $freshDraftReady = atom(false) +export const $busy = atom(false) +export const $awaitingResponse = atom(false) +export const $currentModel = atom('') +export const $currentProvider = atom('') +export const $currentReasoningEffort = atom('') +export const $currentServiceTier = atom('') +export const $currentFastMode = atom(false) +export const $currentCwd = atom('') +export const $currentBranch = atom('') +export const $currentUsage = atom<UsageStats>({ + calls: 0, + input: 0, + output: 0, + total: 0 +}) +export const $sessionStartedAt = atom<number | null>(null) +export const $turnStartedAt = atom<number | null>(null) +export const $introPersonality = atom('') +export const $currentPersonality = atom('') +export const $availablePersonalities = atom<string[]>([]) +export const $introSeed = atom(0) +export const $contextSuggestions = atom<ContextSuggestion[]>([]) +export const $modelPickerOpen = atom(false) + +export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next) +export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next) +export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next) +export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next) +export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next) +export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next) +export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next) +export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next) +export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next) +export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next) +export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next) +export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next) +export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next) +export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next) +export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next) +export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next) +export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next) +export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next) +export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next) +export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next) +export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next) +export const setTurnStartedAt = (next: Updater<number | null>) => updateAtom($turnStartedAt, next) +export const setIntroPersonality = (next: Updater<string>) => updateAtom($introPersonality, next) +export const setCurrentPersonality = (next: Updater<string>) => updateAtom($currentPersonality, next) +export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom($availablePersonalities, next) +export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, next) +export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next) +export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next) + +export function setSessionWorking(sessionId: string | null | undefined, working: boolean) { + if (!sessionId) { + return + } + + setWorkingSessionIds(current => { + const alreadyWorking = current.includes(sessionId) + + if (working) { + return alreadyWorking ? current : [...current, sessionId] + } + + return alreadyWorking ? current.filter(id => id !== sessionId) : current + }) +} diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts new file mode 100644 index 000000000..6dee494e2 --- /dev/null +++ b/apps/desktop/src/store/subagents.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $subagentsBySession, + activeSubagentCount, + buildSubagentTree, + clearSessionSubagents, + pruneDelegateFallbackSubagents, + upsertSubagent +} from './subagents' + +const listFor = (sid: string) => $subagentsBySession.get()[sid] ?? [] + +describe('subagent store', () => { + beforeEach(() => $subagentsBySession.set({})) + + it('upserts subagent progress and keeps terminal status stable', () => { + upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'a1', task_index: 0 }) + upsertSubagent('s1', { goal: 'scan files', status: 'completed', subagent_id: 'a1', summary: 'done', task_index: 0 }) + upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'a1', task_index: 0, text: 'late' }) + + const item = listFor('s1')[0] + expect(item?.status).toBe('completed') + expect(item?.summary).toBe('done') + }) + + it('builds parent/child trees', () => { + upsertSubagent('s1', { goal: 'parent', status: 'running', subagent_id: 'p', task_index: 0 }) + upsertSubagent('s1', { goal: 'child', parent_id: 'p', status: 'queued', subagent_id: 'c', task_index: 1 }) + + const tree = buildSubagentTree(listFor('s1')) + expect(tree).toHaveLength(1) + expect(tree[0]?.children[0]?.goal).toBe('child') + expect(activeSubagentCount(listFor('s1'))).toBe(2) + }) + + it('keeps root nodes in spawn order, not task index order', () => { + const nowSpy = vi.spyOn(Date, 'now') + nowSpy.mockReturnValueOnce(1_000) + upsertSubagent('s1', { goal: 'first spawn', status: 'running', subagent_id: 'a', task_index: 2 }) + nowSpy.mockReturnValueOnce(2_000) + upsertSubagent('s1', { goal: 'second spawn', status: 'running', subagent_id: 'b', task_index: 0 }) + nowSpy.mockRestore() + + expect(buildSubagentTree(listFor('s1')).map(n => n.id)).toEqual(['a', 'b']) + }) + + it('captures live thinking/progress/tool stream lines', () => { + upsertSubagent( + 's1', + { goal: 'scan files', status: 'queued', subagent_id: 'a1', task_index: 0 }, + true, + 'subagent.spawn_requested' + ) + upsertSubagent( + 's1', + { + status: 'running', + subagent_id: 'a1', + task_index: 0, + tool_name: 'search_files', + tool_preview: 'pattern=hermes' + }, + false, + 'subagent.tool' + ) + upsertSubagent( + 's1', + { status: 'running', subagent_id: 'a1', task_index: 0, text: 'plan the search order' }, + false, + 'subagent.thinking' + ) + upsertSubagent( + 's1', + { status: 'running', subagent_id: 'a1', task_index: 0, text: 'found candidate matches' }, + false, + 'subagent.progress' + ) + upsertSubagent( + 's1', + { status: 'completed', subagent_id: 'a1', summary: 'search complete', task_index: 0 }, + false, + 'subagent.complete' + ) + + const item = listFor('s1')[0] + expect(item?.stream.map(e => e.kind)).toEqual(['tool', 'thinking', 'progress', 'summary']) + expect(item?.stream.find(e => e.kind === 'tool')?.text).toContain('Search Files') + expect(item?.stream.find(e => e.kind === 'thinking')?.text).toBe('plan the search order') + expect(item?.stream.find(e => e.kind === 'summary')?.text).toBe('search complete') + }) + + it('prunes delegate fallback rows once native events arrive', () => { + upsertSubagent('s1', { goal: 'fallback', status: 'running', subagent_id: 'delegate-tool:abc:0', task_index: 0 }) + upsertSubagent('s1', { goal: 'native', status: 'running', subagent_id: 'sa-0-xyz', task_index: 0 }) + + pruneDelegateFallbackSubagents('s1') + + expect(listFor('s1').map(item => item.id)).toEqual(['sa-0-xyz']) + }) + + it('clears one session without touching another', () => { + upsertSubagent('s1', { goal: 'one', status: 'running', subagent_id: 'a1', task_index: 0 }) + upsertSubagent('s2', { goal: 'two', status: 'running', subagent_id: 'a2', task_index: 0 }) + + clearSessionSubagents('s1') + + expect($subagentsBySession.get().s1).toBeUndefined() + expect($subagentsBySession.get().s2).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts new file mode 100644 index 000000000..bc94794c0 --- /dev/null +++ b/apps/desktop/src/store/subagents.ts @@ -0,0 +1,260 @@ +import { atom } from 'nanostores' + +export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running' +export type SubagentStreamKind = 'progress' | 'summary' | 'thinking' | 'tool' + +export interface SubagentStreamEntry { + at: number + isError?: boolean + kind: SubagentStreamKind + text: string +} + +export interface SubagentProgress { + id: string + parentId: null | string + goal: string + model?: string + status: SubagentStatus + taskCount: number + taskIndex: number + startedAt: number + updatedAt: number + durationSeconds?: number + costUsd?: number + inputTokens?: number + outputTokens?: number + toolCount?: number + filesRead: string[] + filesWritten: string[] + stream: SubagentStreamEntry[] + summary?: string + /** Active tool while running — cleared on terminal status. */ + currentTool?: string +} + +export interface SubagentNode extends SubagentProgress { + children: SubagentNode[] +} + +export type SubagentPayload = Record<string, unknown> + +const TERMINAL: ReadonlySet<SubagentStatus> = new Set(['completed', 'failed', 'interrupted']) +const MAX_STREAM = 24 +const PREVIEW_MAX = 220 +const TOOL_PREVIEW_MAX = 96 + +export const $subagentsBySession = atom<Record<string, SubagentProgress[]>>({}) + +const isStr = (v: unknown): v is string => typeof v === 'string' +const str = (v: unknown) => (isStr(v) ? v : '') +const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) +const strList = (v: unknown) => (Array.isArray(v) ? v.filter(isStr) : []) + +const asStatus = (v: unknown): SubagentStatus => + v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running' + +const compact = (text: string, max = PREVIEW_MAX) => { + const line = text.replace(/\s+/g, ' ').trim() + + if (!line) { + return '' + } + + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} + +const toolLabel = (name: string) => + name + .split('_') + .filter(Boolean) + .map(p => p[0]!.toUpperCase() + p.slice(1)) + .join(' ') || name + +const formatTool = (name: string, preview = '') => { + const snippet = compact(preview, TOOL_PREVIEW_MAX) + + return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name) +} + +interface TailEntry { + isError?: boolean + preview?: string + tool?: string +} + +const asTail = (v: unknown): TailEntry[] => + Array.isArray(v) + ? v + .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object') + .map(item => ({ + isError: item.is_error === true, + preview: str(item.preview) || undefined, + tool: str(item.tool) || undefined + })) + : [] + +const idOf = (p: SubagentPayload) => + str(p.subagent_id) || `${str(p.parent_id) || 'root'}:${num(p.task_index) ?? 0}:${str(p.goal)}` + +const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => { + const last = stream.at(-1) + + if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) { + return stream + } + + return [...stream, entry].slice(-MAX_STREAM) +} + +function streamFromPayload( + payload: SubagentPayload, + status: SubagentStatus, + eventType: string, + at: number +): SubagentStreamEntry[] { + const out: SubagentStreamEntry[] = [] + const tool = str(payload.tool_name) + const preview = str(payload.tool_preview) || str(payload.text) + const text = compact(str(payload.text) || preview) + + for (const tail of asTail(payload.output_tail)) { + const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '') + + if (line) { + out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line }) + } + } + + if (tool) { + out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) }) + } + + if (eventType === 'subagent.progress' && text) { + out.push({ at, isError: !!payload.error, kind: 'progress', text }) + } + + if (eventType === 'subagent.thinking' && text) { + out.push({ at, kind: 'thinking', text }) + } + + const summary = compact(str(payload.summary) || str(payload.text)) + + if (TERMINAL.has(status) && summary) { + out.push({ at, isError: status === 'failed', kind: 'summary', text: summary }) + } + + return out +} + +function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined, eventType = ''): SubagentProgress { + const at = Date.now() + const status = asStatus(payload.status) + const tool = str(payload.tool_name) + const stream = streamFromPayload(payload, status, eventType, at).reduce(appendStream, prev?.stream ?? []) + const filesRead = strList(payload.files_read) + const filesWritten = strList(payload.files_written) + + return { + id: prev?.id ?? idOf(payload), + parentId: str(payload.parent_id) || prev?.parentId || null, + goal: str(payload.goal) || prev?.goal || 'Subagent', + model: str(payload.model) || prev?.model, + status, + taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1, + taskIndex: num(payload.task_index) ?? prev?.taskIndex ?? 0, + startedAt: prev?.startedAt ?? at, + updatedAt: at, + durationSeconds: num(payload.duration_seconds) ?? prev?.durationSeconds, + costUsd: num(payload.cost_usd) ?? prev?.costUsd, + inputTokens: num(payload.input_tokens) ?? prev?.inputTokens, + outputTokens: num(payload.output_tokens) ?? prev?.outputTokens, + toolCount: num(payload.tool_count) ?? prev?.toolCount, + filesRead: filesRead.length ? filesRead : (prev?.filesRead ?? []), + filesWritten: filesWritten.length ? filesWritten : (prev?.filesWritten ?? []), + stream, + summary: str(payload.summary) || prev?.summary, + currentTool: TERMINAL.has(status) ? undefined : tool || prev?.currentTool + } +} + +export function clearSessionSubagents(sid: string) { + const map = $subagentsBySession.get() + + if (!(sid in map)) { + return + } + + const { [sid]: _drop, ...rest } = map + $subagentsBySession.set(rest) +} + +export function pruneDelegateFallbackSubagents(sid: string) { + const map = $subagentsBySession.get() + const list = map[sid] + + if (!list?.length) { + return + } + + const next = list.filter(item => !item.id.startsWith('delegate-tool:')) + + if (next.length === list.length) { + return + } + + $subagentsBySession.set({ ...map, [sid]: next }) +} + +export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMissing = true, eventType?: string) { + const map = $subagentsBySession.get() + const list = map[sid] ?? [] + const id = idOf(payload) + const idx = list.findIndex(item => item.id === id) + + if (idx < 0 && !createIfMissing) { + return + } + + const prev = idx >= 0 ? list[idx] : undefined + + if (prev && TERMINAL.has(prev.status)) { + return + } + + const next = toProgress(payload, prev, eventType) + const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next] + + $subagentsBySession.set({ ...map, [sid]: nextList }) +} + +export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] { + const nodes = new Map<string, SubagentNode>() + + for (const item of items) { + nodes.set(item.id, { ...item, children: [] }) + } + + const roots: SubagentNode[] = [] + + for (const node of nodes.values()) { + const parent = node.parentId ? nodes.get(node.parentId) : null + + if (parent) { + parent.children.push(node) + } else { + roots.push(node) + } + } + + const sort = (a: SubagentNode, b: SubagentNode) => + a.startedAt - b.startedAt || a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal) + + const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk) + roots.sort(sort).forEach(walk) + + return roots +} + +export const activeSubagentCount = (items: readonly SubagentProgress[]) => + items.filter(item => item.status === 'queued' || item.status === 'running').length diff --git a/apps/desktop/src/store/thread-scroll.ts b/apps/desktop/src/store/thread-scroll.ts new file mode 100644 index 000000000..b577f5040 --- /dev/null +++ b/apps/desktop/src/store/thread-scroll.ts @@ -0,0 +1,11 @@ +import { atom } from 'nanostores' + +export const $threadScrolledUp = atom(false) + +export function setThreadScrolledUp(value: boolean) { + if ($threadScrolledUp.get() === value) { + return + } + + $threadScrolledUp.set(value) +} diff --git a/apps/desktop/src/store/tool-diffs.ts b/apps/desktop/src/store/tool-diffs.ts new file mode 100644 index 000000000..01678bc21 --- /dev/null +++ b/apps/desktop/src/store/tool-diffs.ts @@ -0,0 +1,23 @@ +import { atom } from 'nanostores' + +const $toolDiffs = atom<Record<string, string>>({}) + +export function recordToolDiff(toolCallId: string, diff: string) { + if (!toolCallId || !diff) { + return + } + + const current = $toolDiffs.get() + + if (current[toolCallId] === diff) { + return + } + + $toolDiffs.set({ ...current, [toolCallId]: diff }) +} + +export function getToolDiff(toolCallId: string): string { + return toolCallId ? $toolDiffs.get()[toolCallId] || '' : '' +} + +export const $toolInlineDiffs = $toolDiffs diff --git a/apps/desktop/src/store/tool-view.ts b/apps/desktop/src/store/tool-view.ts new file mode 100644 index 000000000..192932165 --- /dev/null +++ b/apps/desktop/src/store/tool-view.ts @@ -0,0 +1,91 @@ +import { atom, computed, type ReadableAtom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +export type ToolViewMode = 'product' | 'technical' + +type ToolDisclosureStates = Record<string, boolean> + +const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical' +const TOOL_DISCLOSURE_STORAGE_KEY = 'hermes.desktop.toolDisclosure.v1' +const MAX_DISCLOSURE_STATES = 240 + +export const $toolViewMode = atom<ToolViewMode>( + storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product' +) +export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates()) +const disclosureOpenCache = new Map<string, ReadableAtom<boolean | undefined>>() + +$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical')) +$toolDisclosureStates.subscribe(persistToolDisclosureStates) + +export function setToolViewMode(mode: ToolViewMode) { + $toolViewMode.set(mode) +} + +export function $toolDisclosureOpen(id: string): ReadableAtom<boolean | undefined> { + let cached = disclosureOpenCache.get(id) + + if (!cached) { + cached = computed($toolDisclosureStates, states => states[id]) + disclosureOpenCache.set(id, cached) + } + + return cached +} + +function loadToolDisclosureStates(): ToolDisclosureStates { + if (typeof window === 'undefined') { + return {} + } + + try { + const raw = window.localStorage.getItem(TOOL_DISCLOSURE_STORAGE_KEY) + + if (!raw) { + return {} + } + + const parsed = JSON.parse(raw) as unknown + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + return Object.fromEntries( + Object.entries(parsed as Record<string, unknown>) + .filter((entry): entry is [string, boolean] => typeof entry[0] === 'string' && typeof entry[1] === 'boolean') + .slice(-MAX_DISCLOSURE_STATES) + ) + } catch { + return {} + } +} + +function persistToolDisclosureStates(states: ToolDisclosureStates) { + if (typeof window === 'undefined') { + return + } + + try { + const entries = Object.entries(states).slice(-MAX_DISCLOSURE_STATES) + + window.localStorage.setItem(TOOL_DISCLOSURE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries))) + } catch { + // Tool disclosure is a local UI preference; ignore storage failures. + } +} + +export function setToolDisclosureOpen(id: string, open: boolean) { + if (!id) { + return + } + + const current = $toolDisclosureStates.get() + + if (current[id] === open) { + return + } + + $toolDisclosureStates.set({ ...current, [id]: open }) +} diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts new file mode 100644 index 000000000..603b1c742 --- /dev/null +++ b/apps/desktop/src/store/updates.ts @@ -0,0 +1,271 @@ +/** + * Desktop self-update store. Tracks distance from the configured branch, + * surfaces it as an ambient pill, and orchestrates the apply flow. + */ + +import { atom } from 'nanostores' + +import type { + DesktopUpdateApplyOptions, + DesktopUpdateApplyResult, + DesktopUpdateProgress, + DesktopUpdateStage, + DesktopUpdateStatus, + DesktopVersionInfo +} from '@/global' +import { persistString, storedString } from '@/lib/storage' +import { dismissNotification, notify } from '@/store/notifications' + +export interface UpdateApplyState { + applying: boolean + stage: DesktopUpdateStage + message: string + percent: number | null + error: string | null + /** When the stage is 'manual': the exact command the user should run + * (CLI install with no staged updater). */ + command: string | null + log: readonly { stage: DesktopUpdateStage; message: string; at: number }[] +} + +const IDLE: UpdateApplyState = { + applying: false, + stage: 'idle', + message: '', + percent: null, + error: null, + command: null, + log: [] +} + +export const $desktopVersion = atom<DesktopVersionInfo | null>(null) +export const $updateApply = atom<UpdateApplyState>(IDLE) +export const $updateChecking = atom<boolean>(false) +export const $updateOverlayOpen = atom<boolean>(false) +export const $updateStatus = atom<DesktopUpdateStatus | null>(null) + +export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open) +export const resetUpdateApplyState = () => $updateApply.set(IDLE) + +const UPDATE_TOAST_ID = 'desktop-update-available' +const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha' + +// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written +// against. The backend reports its own value in session runtime info; a lower +// value (or none — a pre-GUI checkout) means GUI<->backend skew. +const REQUIRED_BACKEND_CONTRACT = 1 +const SKEW_TOAST_ID = 'backend-contract-skew' + +/** + * Guard against a desktop GUI talking to a backend that predates its contract + * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing + * cryptically downstream, surface a persistent warning with a one-click align + * that runs the normal update flow (which self-heals to the right branch). + */ +export function reportBackendContract(contract: number | undefined): void { + if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) { + dismissNotification(SKEW_TOAST_ID) + + return + } + + notify({ + action: { label: 'Update Hermes', onClick: () => void applyUpdates() }, + durationMs: 0, + id: SKEW_TOAST_ID, + kind: 'warning', + message: + 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.', + title: 'Backend out of date' + }) +} + +function markToastDismissed(sha: string | undefined) { + if (sha) { + persistString(UPDATE_TOAST_DISMISSED_KEY, sha) + } +} + +/** + * Fire a one-shot toast the first time we see a particular target commit so + * users don't have to notice the status-bar version pill turning colors. + * Dismissal is remembered per-target-sha so the toast doesn't keep popping + * back for the same update across restarts. + */ +function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { + if (!status || status.supported === false || status.error || !status.targetSha) { + return + } + + if ((status.behind ?? 0) <= 0) { + return + } + + if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) { + return + } + + if ($updateApply.get().applying) { + return + } + + const behind = status.behind ?? 0 + const targetSha = status.targetSha + + notify({ + action: { + label: "See what's new", + onClick: () => { + markToastDismissed(targetSha) + openUpdatesWindow() + } + }, + durationMs: 0, + id: UPDATE_TOAST_ID, + kind: 'info', + message: `${behind} new change${behind === 1 ? '' : 's'} available.`, + onDismiss: () => markToastDismissed(targetSha), + title: 'Update ready' + }) +} + +/** + * Opens the updates dialog and kicks off a fresh check so the user always + * sees current state, even if a stale status is cached from earlier. + */ +export function openUpdatesWindow(): void { + $updateOverlayOpen.set(true) + void checkUpdates() +} + +export async function checkUpdates(): Promise<DesktopUpdateStatus | null> { + const bridge = window.hermesDesktop?.updates + + if (!bridge || $updateChecking.get()) { + return $updateStatus.get() + } + + $updateChecking.set(true) + + try { + const status = await bridge.check() + $updateStatus.set(status) + maybeNotifyUpdateAvailable(status) + + return status + } catch (error) { + const previous = $updateStatus.get() + + const fallback: DesktopUpdateStatus = { + supported: previous?.supported ?? true, + branch: previous?.branch, + error: 'check-failed', + message: error instanceof Error ? error.message : String(error), + fetchedAt: Date.now() + } + + $updateStatus.set(fallback) + + return fallback + } finally { + $updateChecking.set(false) + } +} + +export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promise<DesktopUpdateApplyResult> { + const bridge = window.hermesDesktop?.updates + + if (!bridge) { + return { ok: false, error: 'unavailable', message: 'Desktop bridge unavailable.' } + } + + dismissNotification(UPDATE_TOAST_ID) + $updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Starting update…' }) + + try { + const result = await bridge.apply(opts) + + // CLI install with no staged updater: not an error — the user just runs + // `hermes update` themselves. Land on a dedicated manual state so the + // overlay shows the command + copy button instead of a dead retry loop. + if (result?.manual) { + $updateApply.set({ + ...IDLE, + applying: false, + stage: 'manual', + message: result.command ?? 'hermes update', + command: result.command ?? 'hermes update' + }) + } + + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + $updateApply.set({ ...$updateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message }) + + return { ok: false, error: 'apply-failed', message } + } +} + +function ingestProgress(payload: DesktopUpdateProgress): void { + const current = $updateApply.get() + const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50) + const terminal = payload.stage === 'error' || payload.stage === 'restart' || payload.stage === 'manual' + + $updateApply.set({ + applying: !terminal, + stage: payload.stage, + message: payload.message, + percent: payload.percent, + error: payload.error, + // 'manual' carries the command to run in its message field. + command: payload.stage === 'manual' ? payload.message : current.command, + log + }) +} + +let pollerStarted = false +let backgroundTimer: ReturnType<typeof setInterval> | null = null +let lastFocusAt = 0 + +/** Wire up background polling + progress streaming. Idempotent. */ +export function startUpdatePoller(): void { + if (pollerStarted || typeof window === 'undefined') { + return + } + + const bridge = window.hermesDesktop?.updates + + if (!bridge) { + return + } + + pollerStarted = true + void checkUpdates() + void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info)) + bridge.onProgress(ingestProgress) + + window.addEventListener('focus', onFocus) + backgroundTimer = setInterval(() => void checkUpdates(), 30 * 60 * 1000) +} + +export function stopUpdatePoller(): void { + if (backgroundTimer !== null) { + clearInterval(backgroundTimer) + backgroundTimer = null + } + + window.removeEventListener('focus', onFocus) + pollerStarted = false +} + +function onFocus() { + const now = Date.now() + + if (now - lastFocusAt < 5 * 60 * 1000) { + return + } + + lastFocusAt = now + void checkUpdates() +} diff --git a/apps/desktop/src/store/voice-playback.ts b/apps/desktop/src/store/voice-playback.ts new file mode 100644 index 000000000..257b1009f --- /dev/null +++ b/apps/desktop/src/store/voice-playback.ts @@ -0,0 +1,24 @@ +import { atom } from 'nanostores' + +export type VoicePlaybackSource = 'read-aloud' | 'voice-conversation' +export type VoicePlaybackStatus = 'idle' | 'preparing' | 'speaking' + +export interface VoicePlaybackState { + audioElement: HTMLAudioElement | null + messageId: string | null + sequence: number + source: VoicePlaybackSource | null + status: VoicePlaybackStatus +} + +export const $voicePlayback = atom<VoicePlaybackState>({ + audioElement: null, + messageId: null, + sequence: 0, + source: null, + status: 'idle' +}) + +export function setVoicePlaybackState(next: VoicePlaybackState) { + $voicePlayback.set(next) +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css new file mode 100644 index 000000000..1a2d333bf --- /dev/null +++ b/apps/desktop/src/styles.css @@ -0,0 +1,957 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; +@import 'tw-shimmer'; +@import 'katex/dist/katex.min.css'; +@import '@vscode/codicons/dist/codicon.css'; +@custom-variant dark (&:is(.dark *)); + +@font-face { + font-family: 'Collapse'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2'); +} + +@theme inline { + --color-background: var(--dt-background); + --color-foreground: var(--dt-foreground); + --color-card: var(--dt-card); + --color-card-foreground: var(--dt-card-foreground); + --color-muted: var(--dt-muted); + --color-muted-foreground: var(--dt-muted-foreground); + --color-popover: var(--dt-popover); + --color-popover-foreground: var(--dt-popover-foreground); + --color-primary: var(--dt-primary); + --color-primary-foreground: var(--dt-primary-foreground); + --color-secondary: var(--dt-secondary); + --color-secondary-foreground: var(--dt-secondary-foreground); + --color-accent: var(--dt-accent); + --color-accent-foreground: var(--dt-accent-foreground); + --color-border: var(--dt-border); + --color-input: var(--dt-input); + --color-ring: var(--dt-ring); + --color-destructive: var(--dt-destructive); + --color-destructive-foreground: var(--dt-destructive-foreground); + + --color-midground: var(--dt-midground); + --color-midground-foreground: var(--dt-midground-foreground); + + --font-sans: var(--dt-font-sans); + --font-mono: var(--dt-font-mono); + + --spacing-mul: var(--dt-spacing-mul, 1); + + --radius-xs: calc(var(--radius-scalar) * 0.125rem); + --radius-sm: calc(var(--radius-scalar) * 0.5rem); + --radius-md: calc(var(--radius-scalar) * 0.625rem); + --radius-lg: calc(var(--radius-scalar) * 0.75rem); + --radius-xl: calc(var(--radius-scalar) * 1rem); + --radius-2xl: calc(var(--radius-scalar) * 1.5rem); + --radius-3xl: calc(var(--radius-scalar) * 2rem); + --radius-4xl: calc(var(--radius-scalar) * 2.5rem); + + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + + --shadow-ink: var(--dt-foreground); + --shadow-xs: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent); + --shadow-sm: + 0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 6%, transparent), + 0 0.125rem 0.5rem color-mix(in srgb, #000 4%, transparent); + --shadow-md: + 0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent), + 0 0.25rem 1rem color-mix(in srgb, #000 8%, transparent), + 0 1rem 2rem -1.5rem color-mix(in srgb, #000 18%, transparent); + --shadow-lg: + inset 0 0.0625rem 0 color-mix(in srgb, #fff 28%, transparent), + 0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent), + 0 0.75rem 2rem color-mix(in srgb, #000 12%, transparent); + --shadow-header: + 0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent), + 0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent); + --shadow-composer: + 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent); + --shadow-composer-focus: + 0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent), + 0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent), + 0 0.25rem 0.875rem color-mix(in srgb, #000 8%, transparent), + 0 0.75rem 2rem -1.25rem color-mix(in srgb, #000 14%, transparent); +} + +@layer base { + :root { + color-scheme: light; + + --theme-foreground: #17171a; + --theme-primary: #0053fd; + --theme-secondary: color-mix(in srgb, #0053fd 7%, #ffffff); + --theme-accent-soft: color-mix(in srgb, #0053fd 10%, #ffffff); + --theme-midground: #0053fd; + --theme-warm: #cf806d; + --theme-background-seed: #f8faff; + --theme-sidebar-seed: #f3f7ff; + --theme-card-seed: #ffffff; + --theme-elevated-seed: #ffffff; + --theme-bubble-seed: color-mix(in srgb, #0053fd 6%, #ffffff); + --theme-neutral-chrome: #f3f3f3; + --theme-neutral-sidebar: #f3f3f3; + --theme-neutral-card: #fcfcfc; + --theme-mix-chrome: 92%; + --theme-mix-sidebar: 100%; + --theme-mix-card: 22%; + --theme-mix-elevated: 28%; + --theme-mix-bubble: 0%; + --theme-fill-primary-accent-mix: 16%; + --theme-fill-secondary-accent-mix: 11%; + --theme-fill-tertiary-accent-mix: 8%; + --theme-fill-quaternary-accent-mix: 5%; + --theme-fill-quinary-accent-mix: 3%; + --theme-stroke-primary-accent-mix: 24%; + --theme-stroke-secondary-accent-mix: 16%; + --theme-stroke-tertiary-accent-mix: 10%; + --theme-stroke-quaternary-accent-mix: 6%; + --theme-row-hover-accent-mix: 4%; + --theme-row-active-accent-mix: 8%; + --theme-control-hover-accent-mix: 6%; + --theme-control-active-accent-mix: 8%; + + --ui-base: var(--theme-foreground); + --ui-accent: var(--theme-midground); + --ui-accent-secondary: var(--theme-primary); + --ui-warm: var(--theme-warm); + --ui-red: #cf2d56; + --ui-orange: #db704b; + --ui-yellow: #c08532; + --ui-green: #1f8a65; + --ui-cyan: #4c7f8c; + --ui-blue: #0053fd; + --ui-purple: #9e94d5; + --ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome)); + --ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar)); + --ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card)); + --ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card)); + --ui-bg-card: color-mix( + in srgb, + var(--ui-accent) 4%, + color-mix(in srgb, var(--ui-base) 4%, transparent) + ); + --ui-bg-input: #fcfcfc; + --ui-bg-primary: color-mix( + in srgb, + var(--ui-accent) var(--theme-fill-primary-accent-mix), + color-mix(in srgb, var(--ui-base) 10%, transparent) + ); + --ui-bg-secondary: color-mix( + in srgb, + var(--ui-accent) var(--theme-fill-secondary-accent-mix), + color-mix(in srgb, var(--ui-base) 7%, transparent) + ); + --ui-bg-tertiary: color-mix( + in srgb, + var(--ui-accent) var(--theme-fill-tertiary-accent-mix), + color-mix(in srgb, var(--ui-base) 5%, transparent) + ); + --ui-bg-quaternary: color-mix( + in srgb, + var(--ui-accent) var(--theme-fill-quaternary-accent-mix), + color-mix(in srgb, var(--ui-base) 4%, transparent) + ); + --ui-bg-quinary: color-mix( + in srgb, + var(--ui-accent) var(--theme-fill-quinary-accent-mix), + color-mix(in srgb, var(--ui-base) 3%, transparent) + ); + --ui-row-hover-background: color-mix( + in srgb, + var(--ui-accent) var(--theme-row-hover-accent-mix), + color-mix(in srgb, var(--ui-base) 3%, transparent) + ); + --ui-row-active-background: color-mix( + in srgb, + var(--ui-accent) var(--theme-row-active-accent-mix), + color-mix(in srgb, var(--ui-base) 5%, transparent) + ); + --ui-control-hover-background: color-mix( + in srgb, + var(--ui-accent) var(--theme-control-hover-accent-mix), + color-mix(in srgb, var(--ui-base) 4%, transparent) + ); + --ui-control-active-background: color-mix( + in srgb, + var(--ui-accent) var(--theme-control-active-accent-mix), + color-mix(in srgb, var(--ui-base) 5%, transparent) + ); + --ui-text-primary: color-mix(in srgb, var(--ui-base) 94%, transparent); + --ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent); + --ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent); + --ui-text-quaternary: color-mix(in srgb, var(--ui-base) 36%, transparent); + --ui-stroke-primary: color-mix( + in srgb, + var(--ui-accent) var(--theme-stroke-primary-accent-mix), + color-mix(in srgb, var(--ui-base) 10%, transparent) + ); + --ui-stroke-secondary: color-mix( + in srgb, + var(--ui-accent) var(--theme-stroke-secondary-accent-mix), + color-mix(in srgb, var(--ui-base) 7%, transparent) + ); + --ui-stroke-tertiary: color-mix( + in srgb, + var(--ui-accent) var(--theme-stroke-tertiary-accent-mix), + color-mix(in srgb, var(--ui-base) 5%, transparent) + ); + --ui-stroke-quaternary: color-mix( + in srgb, + var(--ui-accent) var(--theme-stroke-quaternary-accent-mix), + color-mix(in srgb, var(--ui-base) 3%, transparent) + ); + --ui-sash-hover-border: color-mix(in srgb, var(--ui-accent) 18%, var(--ui-stroke-tertiary)); + --ui-sash-hover-background: color-mix(in srgb, var(--ui-accent) 6%, transparent); + --ui-surface-background: var(--ui-bg-editor); + --ui-sidebar-surface-background: var(--ui-bg-sidebar); + --ui-chat-surface-background: var(--ui-bg-chrome); + --ui-editor-surface-background: var(--ui-bg-chrome); + --ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card)); + --ui-chat-bubble-opaque-background: var(--ui-bg-editor); + --ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent); + --ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent); + --ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent); + --ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent); + + --dt-background: var(--ui-bg-chrome); + --dt-foreground: var(--ui-text-primary); + --dt-card: var(--ui-bg-editor); + --dt-card-foreground: var(--ui-text-primary); + --dt-muted: var(--ui-bg-tertiary); + --dt-muted-foreground: var(--ui-text-tertiary); + --dt-popover: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent); + --dt-popover-foreground: var(--ui-text-primary); + --dt-primary: var(--theme-primary); + --dt-primary-foreground: #fcfcfc; + --dt-secondary: var(--theme-secondary); + --dt-secondary-foreground: var(--ui-text-secondary); + --dt-accent: var(--theme-accent-soft); + --dt-accent-foreground: var(--ui-text-primary); + --dt-border: var(--ui-stroke-secondary); + --dt-input: var(--ui-stroke-primary); + --dt-ring: var(--ui-stroke-primary); + --dt-midground: var(--theme-midground); + --dt-composer-ring: var(--ui-base); + --dt-destructive: #cf2d56; + --dt-destructive-foreground: #ffffff; + --dt-sidebar-bg: var(--ui-bg-sidebar); + --dt-sidebar-border: var(--ui-stroke-secondary); + --dt-user-bubble: var(--ui-chat-bubble-background); + --dt-user-bubble-border: var(--ui-stroke-tertiary); + + --dt-font-sans: 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + --dt-font-mono: 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace; + --dt-base-size: 1rem; + --dt-line-height: 1.5; + --dt-letter-spacing: 0; + --dt-spacing-mul: 1; + + --radius: 0.75rem; + --radius-scalar: 0.6; + + /* Space under last message vs overlay composer — driven by the measured composer height (see composer/index.tsx). */ + --thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem); + + --composer-shell-pad-block-end: 0.625rem; + --message-text-indent: 0.75rem; + --conversation-text-font-size: 0.8125rem; + --conversation-tool-font-size: var(--conversation-text-font-size); + --conversation-caption-font-size: 0.75rem; + --conversation-line-height: 1.125rem; + --conversation-caption-line-height: 1rem; + --conversation-turn-gap: 0.375rem; + --file-tree-row-height: 1.375rem; + + --composer-width: 48.75rem; + --composer-control-size: 1.75rem; + --composer-control-primary-size: 1.875rem; + --composer-control-gap: 0.25rem; + --composer-row-gap: 0.25rem; + --composer-ring-strength: 1; + --composer-surface-pad-x: 0.5rem; + --composer-surface-pad-y: 0.3125rem; + --composer-input-min-height: 1.625rem; + --composer-input-max-height: 9.375rem; + --composer-input-inline-min-width: 8rem; + --composer-fallback-height: 2.75rem; + --composer-measured-height: calc(0.5rem + var(--composer-shell-pad-block-end) + var(--composer-fallback-height)); + --composer-surface-measured-height: var(--composer-fallback-height); + --thread-viewport-height: max( + 0rem, + calc(100% - var(--composer-measured-height) + var(--composer-surface-measured-height)) + ); + --vsq: min(0.5vh, 0.5vw); + --image-preview-max-width: 34rem; + --image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem); + + --sidebar-width: 14.8125rem; + --chat-min-width: 28rem; + --titlebar-control-size: 1.25rem; + --titlebar-control-height: 1.375rem; + --sidebar-content-inline-padding: 1rem; + + --sidebar: var(--dt-sidebar-bg); + --sidebar-foreground: var(--dt-foreground); + --sidebar-primary: var(--dt-primary); + --sidebar-primary-foreground: var(--dt-primary-foreground); + --sidebar-accent: var(--ui-control-active-background); + --sidebar-accent-foreground: var(--dt-accent-foreground); + --sidebar-border: var(--dt-sidebar-border); + --sidebar-ring: var(--dt-ring); + --sidebar-edge-border: color-mix(in srgb, var(--ui-base) 7.5%, transparent); + --chrome-action-hover: var(--ui-control-hover-background); + + --midground: var(--dt-midground); + --background: var(--dt-background); + --foreground: var(--dt-foreground); + + --warm-glow: color-mix(in srgb, var(--ui-warm) 32%, color-mix(in srgb, var(--ui-accent) 6%, transparent)); + /* `--noise-opacity-mul` is set per-mode by `applyTheme()`. */ + --noise-opacity-mul: 1; + --backdrop-invert-mul: 1; + } + + :root.dark { + /* Per-mode mix knobs — overridden inline by `applyTheme()` per skin. */ + --theme-mix-chrome: 74%; + --theme-mix-card: 38%; + --theme-mix-elevated: 46%; + --theme-mix-bubble: 46%; + --theme-neutral-chrome: #0d0d0e; + --theme-neutral-sidebar: #0a0a0b; + --theme-neutral-card: #161618; + + /* Dark-only accent palette overrides. */ + --ui-red: #e75e78; + --ui-green: #55a583; + --ui-cyan: #6f9ba6; + + --sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent); + --composer-ring-strength: 1.3; + --backdrop-invert-mul: 0; + + --ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent); + --ui-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent); + --ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent); + --ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent); + } + + * { + box-sizing: border-box; + border-color: var(--dt-border); + } + + html, + body, + #root { + height: 100%; + } + + html { + font-size: var(--dt-base-size, 0.875rem); + } + + body { + margin: 0; + background: var(--ui-chat-surface-background); + color: var(--dt-foreground); + font-family: var(--dt-font-sans); + font-size: 0.8125rem; + line-height: var(--dt-line-height, 1.55); + letter-spacing: var(--dt-letter-spacing, 0); + overflow: hidden; + -webkit-user-select: none; + user-select: none; + -webkit-font-smoothing: antialiased; + } + + button, + textarea { + font: inherit; + } + + :where( + a, + .underline, + [class~='hover:underline'], + [class~='focus:underline'], + [class~='focus-visible:underline'], + [class~='group-hover:underline'], + [class~='peer-hover:underline'] + ) { + text-decoration-color: color-mix(in srgb, currentColor 20%, transparent); + text-underline-offset: 0.25rem; + } + + *::selection { + background: var(--ui-selection-background); + color: inherit; + } +} + +.dither { + background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem; +} + +:root:not([style*='--theme-asset-bg:']) .theme-default-filler { + display: block; +} + +:root[style*='--theme-asset-bg:'] .theme-default-filler { + display: none; +} + +@layer utilities { + [class*='rounded-full'], + [class*=':rounded-full'] { + border-radius: calc(var(--radius-scalar) * 9999rem); + } +} + +@keyframes arc-border { + 0% { + background-position: 15% 15%; + } + 100% { + background-position: 75% 75%; + } +} + +.arc-border { + --arc-c0: color-mix(in srgb, var(--dt-foreground) 0%, transparent); + --arc-c1: var(--dt-midground); + --arc-c2: var(--dt-background); + --arc-angle: 160deg; + --arc-width: 0.078125rem; + --arc-inset: -0.125rem; + --arc-duration: 2.23s; + + pointer-events: none; + position: absolute; + overflow: hidden; + border-radius: inherit; + inset: var(--arc-inset); + padding: var(--arc-width); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +:root.dark .arc-border { + --arc-c1: var(--dt-foreground); +} + +.arc-border::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + var(--arc-angle), + transparent 0%, + var(--arc-c0) 15%, + var(--arc-c1) 20%, + var(--arc-c2) 25%, + transparent 35%, + transparent 40%, + var(--arc-c0) 55%, + var(--arc-c1) 60%, + var(--arc-c2) 65%, + transparent 75%, + transparent 80%, + var(--arc-c0) 95%, + var(--arc-c1) 100% + ); + background-size: 300% 300%; + animation: arc-border var(--arc-duration) linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .arc-border::before { + animation: none; + } +} + +button { + -webkit-app-region: no-drag; +} + +[data-slot='button'] { + box-shadow: none; + transition-duration: 100ms; +} + +[data-slot='button'][data-variant='outline'], +[data-slot='button'][data-variant='secondary'] { + border-color: var(--ui-stroke-secondary); + background: var(--ui-bg-tertiary); + color: var(--ui-text-primary); +} + +[data-slot='button'][data-variant='ghost'] { + color: var(--ui-text-secondary); +} + +[data-slot='button'][data-variant='outline']:hover, +[data-slot='button'][data-variant='secondary']:hover, +[data-slot='button'][data-variant='ghost']:hover { + background: var(--chrome-action-hover); + color: var(--ui-text-primary); +} + +[data-slot='dropdown-menu-content'], +[data-slot='select-content'], +[data-slot='dialog-content'] { + border-color: var(--ui-stroke-secondary); + background: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent); + box-shadow: var(--shadow-md); + backdrop-filter: blur(0.75rem) saturate(1.08); + -webkit-backdrop-filter: blur(0.75rem) saturate(1.08); +} + +[data-slot='dropdown-menu-item']:focus, +[data-slot='dropdown-menu-checkbox-item']:focus, +[data-slot='dropdown-menu-radio-item']:focus { + background: var(--ui-bg-tertiary); + color: var(--ui-text-primary); +} + +input, +textarea, +[contenteditable]:not([contenteditable='false']), +[data-slot='aui_user-message-root'], +[data-slot='aui_assistant-message-content'], +[data-selectable-text='true'], +[data-selectable-text='true'] * { + -webkit-user-select: text; + user-select: text; +} + +button, +[role='button'] { + -webkit-user-select: none; + user-select: none; +} + +img, +picture, +video, +canvas, +svg { + -webkit-user-select: none; + user-select: none; +} + +img, +video, +canvas { + -webkit-user-drag: none; +} + +/* Shared input chrome — mirrors composer hover/focus FX. Unlayered to beat Tailwind utilities. */ +.desktop-input-chrome { + --ring-pct: 18%; + --ring-fall: var(--dt-input); + background: color-mix(in srgb, var(--dt-card) 68%, transparent); + border-color: color-mix( + in srgb, + var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)), + var(--ring-fall) + ); + box-shadow: var(--shadow-composer); + transition: + background-color 200ms ease-out, + border-color 200ms ease-out, + box-shadow 200ms ease-out; +} + +.desktop-input-chrome:hover { + --ring-pct: 30%; + background: color-mix(in srgb, var(--dt-card) 86%, transparent); +} + +.desktop-input-chrome:focus { + --ring-pct: 45%; + --ring-fall: transparent; + background: var(--dt-card); + box-shadow: var(--shadow-composer-focus); + outline: none; +} + +.desktop-input-chrome[aria-invalid='true'] { + border-color: var(--dt-destructive); +} + +.desktop-input-chrome[aria-invalid='true']:focus { + box-shadow: + 0 0 0 0.125rem color-mix(in srgb, var(--dt-destructive) 18%, transparent), + 0 0 0 0.0625rem color-mix(in srgb, var(--dt-destructive) 34%, transparent), + 0 0.1875rem 0.625rem color-mix(in srgb, var(--dt-destructive) 12%, transparent); +} + +@layer components { + .scrollbar-dt, + .scrollbar-dt * { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--dt-midground) 18%, transparent) transparent; + } + + .scrollbar-dt::-webkit-scrollbar, + .scrollbar-dt *::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; + } + + .scrollbar-dt::-webkit-scrollbar-track, + .scrollbar-dt::-webkit-scrollbar-corner, + .scrollbar-dt *::-webkit-scrollbar-track, + .scrollbar-dt *::-webkit-scrollbar-corner { + background: transparent; + } + + .scrollbar-dt::-webkit-scrollbar-thumb, + .scrollbar-dt *::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--dt-midground) 18%, transparent); + border-radius: 9999rem; + border: 0.125rem solid transparent; + background-clip: padding-box; + } + + .scrollbar-dt::-webkit-scrollbar-thumb:hover, + .scrollbar-dt *::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--dt-midground) 40%, transparent); + background-clip: padding-box; + } + + .scrollbar-dt::-webkit-scrollbar-button, + .scrollbar-dt *::-webkit-scrollbar-button { + display: none; + } +} + +/* Bottom clearance lives on [data-slot='aui_composer-clearance'] — + virtualized items unmount, so :nth-last-child can't fire reliably. */ + +[data-slot='aui_assistant-message-content'] { + padding-left: var(--message-text-indent); + font-size: var(--conversation-text-font-size); + line-height: 1.5; +} + +[data-slot='aui_assistant-message-root'] { + width: 100%; +} + +[data-slot='aui_assistant-message-content'] .aui-md, +[data-slot='aui_assistant-message-content'] .aui-md :where(p, li, blockquote, table, pre) { + font-size: inherit; +} + +/* Streamed prose hangs slightly indented from the tool/todo column so the + reading column reads as a "reply" within the conversation gutter. Tools, + todos, and thinking blocks keep the existing --message-text-indent so they + remain flush with the user message text above them. */ +[data-slot='aui_assistant-message-content'] > .aui-md { + padding-inline-start: var(--md-text-indent, 0.5rem); +} + +[data-slot='aui_user-message-root'], +[data-slot='aui_edit-composer-root'] { + font-size: var(--conversation-text-font-size); +} + +[data-slot='aui_thread-content'] { + max-width: var(--composer-width); + padding-inline: 1.5rem; +} + +[data-slot='aui_intro'] { + align-items: center; + justify-content: center; + padding-bottom: var(--composer-measured-height); + text-align: center; +} + +[data-slot='aui_intro'] > div { + max-width: min(var(--composer-width), 82vw); +} + +[data-slot='aui_intro'] p:last-child { + max-width: 34rem; + margin-inline: auto; + color: var(--ui-text-tertiary); + font-size: 0.875rem; + line-height: 1.45; +} + +.fit-text { + display: flex; + font-size: var(--fit-text-min, 1rem); + container-type: inline-size; + --captured-length: initial; + --support-sentinel: var(--captured-length, 9999px); +} + +.fit-text > [aria-hidden='true'] { + visibility: hidden; +} + +.fit-text > :not([aria-hidden='true']) { + flex-grow: 1; + container-type: inline-size; + --captured-length: 100cqi; + --available-space: var(--captured-length); +} + +.fit-text > :not([aria-hidden='true']) > * { + display: block; + inline-size: var(--available-space); + line-height: var(--fit-text-line-height, 1); + --support-sentinel: inherit; + --captured-length: 100cqi; + --ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length))); + --font-size: clamp( + var(--fit-text-min, 1em), + 1em * var(--ratio), + var(--fit-text-max, infinity * 1px) - var(--support-sentinel) + ); + font-size: var(--font-size); +} + +@container (inline-size > 0) { + .fit-text > :not([aria-hidden='true']) > * { + white-space: nowrap; + } +} + +@property --captured-length { + syntax: '<length>'; + initial-value: 0px; + inherits: true; +} + +@property --captured-length2 { + syntax: '<length>'; + initial-value: 0px; + inherits: true; +} + +[data-slot='composer-root'] { + width: min(var(--composer-width), calc(100% - 2rem)); + padding-bottom: var(--composer-shell-pad-block-end); +} + +[data-slot='composer-root'] > .pointer-events-none { + background: linear-gradient( + to bottom, + transparent, + color-mix(in srgb, var(--ui-chat-surface-background) 88%, transparent) + ) !important; +} + +[data-slot='composer-surface'] { + border-color: var(--ui-stroke-secondary) !important; + box-shadow: var(--shadow-composer) !important; +} + +[data-slot='composer-fade'] { + min-height: 2.375rem; +} + +[data-slot='composer-rich-input'] { + color: var(--ui-text-primary); + font-size: 0.8125rem; +} + +[data-slot='composer-rich-input']:empty::before { + color: var(--ui-text-tertiary) !important; +} + +[data-slot='composer-root']:focus-within [data-slot='composer-surface'] > [aria-hidden='true'] { + background: var(--ui-chat-bubble-background) !important; +} + +/* Tool/thinking blocks now live at message-text alignment (no leading + chevron column to escape into), so their headers and bodies share a + common left edge with the model's text. */ +[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'], +[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] { + width: 100%; + max-width: 100%; +} + +[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code { + max-width: none; + font-family: inherit; + font-size: inherit; + padding: 0; + border-radius: 0; + background: transparent; + color: inherit; + overflow-x: visible; + overflow-wrap: inherit; + vertical-align: baseline; + word-break: inherit; + white-space: inherit; +} + +/* Streamdown's adapter wraps code fences in a `data-streamdown="code-block"` + container with its own card chrome. We render our own <CodeCard>, so this + strips the upstream chrome down to a layout-only passthrough. */ +[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] { + contain: none; + overflow: visible; + margin-block: 0.375rem !important; + padding: 0 !important; + gap: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + color: inherit; +} + +[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block']:has(.aui-prose-fence) { + margin-block: 0 !important; +} + +[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code { + border: 0.0625rem solid var(--ui-inline-code-border); + background: var(--ui-inline-code-background); + color: var(--ui-inline-code-foreground); +} + +[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-shiki, .aui-shiki > pre) { + margin: 0 !important; +} + +[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table { + border-spacing: 0; +} + +[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table > table, +[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table thead, +[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table tbody, +[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table tr, +[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table th, +[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table td { + margin: 0 !important; + margin-block-start: 0 !important; + margin-block-end: 0 !important; +} + +/* Tool / thinking blocks are scaffolding around the model's reply, so we + keep them transparent and fade them slightly. The reading column (prose) + stays at full strength; scaffolding lifts back to full opacity on + hover/focus so it stays legible when the user actually wants to read it. */ +[data-slot='tool-block'], +[data-slot='aui_thinking-disclosure'] { + background: transparent !important; +} + +[data-slot='aui_assistant-message-content'] + > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) { + opacity: 0.67; + transition: opacity 120ms ease-out; +} + +[data-slot='aui_assistant-message-content'] + > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']):is(:hover, :focus-within) { + opacity: 1; +} + +/* Conversation block rhythm. Consecutive tool calls stay tight so a step + sequence reads as one action group; the gap between any scaffolding + block and adjacent prose bumps up so the model's reply visually + separates from its scaffolding. */ +[data-slot='tool-block'] + [data-slot='tool-block'] { + margin-top: 0.375rem; +} + +[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] { + margin-top: 0.625rem; +} + +[data-slot='aui_assistant-message-content'] + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) + + .aui-md, +[data-slot='aui_assistant-message-content'] + .aui-md + + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) { + margin-top: 1rem; +} + +[data-slot='aui_assistant-message-content'] + [data-slot='aui_thinking-disclosure'] + + [data-slot='tool-block'], +[data-slot='aui_assistant-message-content'] + [data-slot='tool-block'] + + [data-slot='aui_thinking-disclosure'] { + margin-top: 0.75rem; +} + +[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child { + margin-top: 0; +} + +/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */ +[data-slot='aui_msg-actions'] button { + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + padding: 0; + gap: 0; + height: auto; + width: auto; + min-height: 0; + min-width: 0; + flex-shrink: 0; + cursor: pointer; + color: var(--color-muted-foreground); + opacity: 0.5; +} + +[data-slot='aui_msg-actions'] button:disabled { + cursor: default; +} + +[data-slot='aui_msg-actions'] button:hover { + background: transparent; + color: var(--color-foreground); + opacity: 1; +} + +[data-slot='aui_msg-actions'] button:active { + background: transparent; +} + +[data-slot='aui_msg-actions'] button:focus-visible { + opacity: 1; +} + +[data-slot='aui_msg-actions'] button svg { + width: 0.875rem; + height: 0.875rem; +} + +/* Live thinking preview window. Pairs with the ResizeObserver in + ThinkingDisclosure that pins scrollTop to the bottom — older lines fade + into the top mask while the latest tokens settle in below. */ +.thinking-preview { + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%); + mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%); +} diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx new file mode 100644 index 000000000..4ee328245 --- /dev/null +++ b/apps/desktop/src/themes/context.tsx @@ -0,0 +1,334 @@ +/** + * Desktop theme context. + * + * Applies the active theme as CSS custom properties on :root so every + * Tailwind utility that references a color or font-family token picks up + * the change automatically. + * + * Mode (light/dark/system) controls brightness; skin controls accent. + * The two are persisted independently. Shift+X toggles light/dark. + */ + +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' + +import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query' + +import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets' +import type { DesktopTheme, DesktopThemeColors } from './types' + +const SKIN_KEY = 'hermes-desktop-theme-v2' +const MODE_KEY = 'hermes-desktop-mode-v1' +const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold']) + +export type ThemeMode = 'light' | 'dark' | 'system' + +const INJECTED_FONT_URLS = new Set<string>() + +const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' => + mode === 'system' ? (systemDark ? 'dark' : 'light') : mode + +const normalizeSkin = (name: string | null | undefined): string => + name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME + +// ─── Color math (for synthesised light variants of dark-only skins) ──────── + +function hexToRgb(hex: string): [number, number, number] | null { + const clean = hex.trim().replace(/^#/, '') + + if (!/^[0-9a-f]{6}$/i.test(clean)) { + return null + } + + return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number] +} + +const rgbToHex = ([r, g, b]: [number, number, number]) => + `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}` + +function mix(a: string, b: string, amount: number): string { + const ar = hexToRgb(a) + const br = hexToRgb(b) + + return ar && br + ? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount]) + : a +} + +function readableOn(hex: string): string { + const rgb = hexToRgb(hex) + + if (!rgb) { + return '#ffffff' + } + + const [r, g, b] = rgb.map(v => { + const c = v / 255 + + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + }) + + return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff' +} + +function synthLightColors(seed: DesktopTheme): DesktopThemeColors { + const accent = seed.colors.ring || seed.colors.primary + const soft = mix('#ffffff', accent, 0.1) + const softer = mix('#ffffff', accent, 0.06) + const border = mix('#ececef', accent, 0.14) + const midground = seed.colors.midground ?? accent + + return { + background: '#ffffff', + foreground: '#161616', + card: '#ffffff', + cardForeground: '#161616', + muted: softer, + mutedForeground: mix('#6b6b70', accent, 0.16), + popover: '#ffffff', + popoverForeground: '#161616', + primary: accent, + primaryForeground: readableOn(accent), + secondary: soft, + secondaryForeground: mix('#2a2a2a', accent, 0.34), + accent: soft, + accentForeground: mix('#2a2a2a', accent, 0.34), + border, + input: mix('#e2e2e6', accent, 0.18), + ring: accent, + midground, + midgroundForeground: readableOn(midground), + destructive: '#b94a3a', + destructiveForeground: '#ffffff', + sidebarBackground: mix('#fafafa', accent, 0.05), + sidebarBorder: border, + userBubble: soft, + userBubbleBorder: border + } +} + +/** Returns the seed palette for a given skin + mode (no overrides applied). */ +export function getBaseColors(skinName: string, mode: 'light' | 'dark'): DesktopThemeColors { + const seed = BUILTIN_THEMES[skinName] ?? nousTheme + + if (mode === 'dark') { + return seed.darkColors ?? seed.colors + } + + return seed.darkColors ? seed.colors : synthLightColors(seed) +} + +function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme { + const seed = BUILTIN_THEMES[skinName] ?? nousTheme + + return { + ...seed, + name: `${skinName}-${mode}`, + label: `${seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`, + description: `${seed.label} ${mode} palette`, + colors: getBaseColors(skinName, mode) + } +} + +/** + * Some palettes intentionally keep a bright background even when + * `mode === 'dark'`, so we shouldn't apply the `.dark` class. Decide from + * the actual background luminance. + */ +function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'light' | 'dark' { + const rgb = hexToRgb(colors.background) + + if (!rgb) { + return mode + } + + const [r, g, b] = rgb.map(v => v / 255) + + return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 ? 'light' : 'dark' +} + +// ─── CSS application ──────────────────────────────────────────────────────── + +// Per-mode mix knobs. Light/dark fallbacks live in styles.css `:root` / +// `:root.dark`; setting them inline keeps active-skin overrides surviving +// the boot-time paint. +const mixesFor = (isDark: boolean): Record<string, string> => ({ + '--theme-mix-chrome': isDark ? '74%' : '92%', + '--theme-mix-sidebar': '100%', + '--theme-mix-card': isDark ? '38%' : '22%', + '--theme-mix-elevated': isDark ? '46%' : '28%', + '--theme-mix-bubble': isDark ? '46%' : '0%' +}) + +function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') { + if (typeof document === 'undefined') { + return + } + + const root = document.documentElement + const c = theme.colors + const typo = { ...DEFAULT_TYPOGRAPHY, ...nousTheme.typography, ...theme.typography } + const rendered = renderedModeFor(c, mode) + const isDark = rendered === 'dark' + const midground = c.midground ?? c.ring + const skinName = theme.name.endsWith(`-${mode}`) ? theme.name.slice(0, -mode.length - 1) : theme.name + + root.style.setProperty('color-scheme', rendered) + root.dataset.hermesTheme = skinName + root.dataset.hermesMode = rendered + root.classList.toggle('dark', isDark) + + // Brand seeds feed every glass + shadcn token via `color-mix()` in styles.css. + const seeds: Record<string, string> = { + '--theme-foreground': c.foreground, + '--theme-primary': c.primary, + '--theme-secondary': c.secondary, + '--theme-accent-soft': c.accent, + '--theme-midground': midground, + '--theme-warm': c.primary, + '--theme-background-seed': c.background, + '--theme-sidebar-seed': c.sidebarBackground ?? c.background, + '--theme-card-seed': c.card, + '--theme-elevated-seed': c.popover, + '--theme-bubble-seed': c.userBubble ?? c.popover + } + + // shadcn/Tailwind tokens that aren't derived from the seed chain. + const palette: Record<string, string> = { + '--dt-primary-foreground': c.primaryForeground, + '--dt-secondary-foreground': c.secondaryForeground, + '--dt-accent-foreground': c.accentForeground, + '--dt-border': c.border, + '--dt-input': c.input, + '--dt-ring': c.ring, + '--dt-muted': c.muted, + '--dt-midground-foreground': c.midgroundForeground ?? readableOn(midground), + '--dt-composer-ring': c.composerRing ?? midground, + '--dt-destructive': c.destructive, + '--dt-destructive-foreground': c.destructiveForeground, + '--dt-sidebar-border': c.sidebarBorder ?? c.border, + '--dt-user-bubble-border': c.userBubbleBorder ?? c.border, + '--dt-font-sans': typo.fontSans, + '--dt-font-mono': typo.fontMono, + '--noise-opacity-mul': isDark ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)' + } + + for (const [k, v] of Object.entries({ ...seeds, ...mixesFor(isDark), ...palette })) { + root.style.setProperty(k, v) + } + + window.hermesDesktop?.setTitleBarTheme?.({ + background: c.background, + foreground: c.foreground + }) + + if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = typo.fontUrl + link.dataset.hermesThemeFont = 'true' + document.head.appendChild(link) + INJECTED_FONT_URLS.add(typo.fontUrl) + } +} + +// Boot-time paint to avoid a flash before <ThemeProvider> mounts. +if (typeof window !== 'undefined') { + const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY)) + const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light' + const resolved = resolveMode(mode) + applyTheme(deriveTheme(skin, resolved), resolved) +} + +// ─── Context ──────────────────────────────────────────────────────────────── + +interface ThemeContextValue { + theme: DesktopTheme + themeName: string + mode: ThemeMode + resolvedMode: 'light' | 'dark' + availableThemes: Array<{ name: string; label: string; description: string }> + setTheme: (name: string) => void + setMode: (mode: ThemeMode) => void +} + +const SKIN_LIST = BUILTIN_THEME_LIST.map(({ name, label, description }) => ({ name, label, description })) + +const ThemeContext = createContext<ThemeContextValue>({ + theme: nousTheme, + themeName: DEFAULT_SKIN_NAME, + mode: 'light', + resolvedMode: 'light', + availableThemes: SKIN_LIST, + setTheme: () => {}, + setMode: () => {} +}) + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [themeName, setThemeNameState] = useState(() => + typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY)) + ) + + const [mode, setModeState] = useState<ThemeMode>(() => + typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light') + ) + + const systemDark = useMediaQuery('(prefers-color-scheme: dark)') + const resolvedMode = resolveMode(mode, systemDark) + const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode]) + + useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode]) + + const setTheme = useCallback((name: string) => { + const next = normalizeSkin(name) + setThemeNameState(next) + window.localStorage.setItem(SKIN_KEY, next) + }, []) + + const setMode = useCallback((next: ThemeMode) => { + setModeState(next) + window.localStorage.setItem(MODE_KEY, next) + }, []) + + // Shift+X toggles light/dark anywhere outside an editable field. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const t = event.target as HTMLElement | null + + const editing = + t?.isContentEditable || + t instanceof HTMLInputElement || + t instanceof HTMLTextAreaElement || + t instanceof HTMLSelectElement + + if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) { + return + } + + if (event.shiftKey && event.code === 'KeyX') { + setMode(resolvedMode === 'dark' ? 'light' : 'dark') + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [resolvedMode, setMode]) + + const value = useMemo<ThemeContextValue>( + () => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes: SKIN_LIST, setTheme, setMode }), + [activeTheme, themeName, mode, resolvedMode, setTheme, setMode] + ) + + return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> +} + +export const useTheme = (): ThemeContextValue => useContext(ThemeContext) + +/** Sync the desktop skin with the active Hermes backend theme on connect. */ +export function useSyncThemeFromBackend(backendThemeName: string | undefined, setTheme: (name: string) => void) { + useEffect(() => { + if (backendThemeName && BUILTIN_THEMES[backendThemeName]) { + setTheme(backendThemeName) + } + }, [backendThemeName, setTheme]) +} diff --git a/apps/desktop/src/themes/index.ts b/apps/desktop/src/themes/index.ts new file mode 100644 index 000000000..d33c752c0 --- /dev/null +++ b/apps/desktop/src/themes/index.ts @@ -0,0 +1,3 @@ +export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context' +export { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets' +export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types' diff --git a/apps/desktop/src/themes/presets.ts b/apps/desktop/src/themes/presets.ts new file mode 100644 index 000000000..170b5456e --- /dev/null +++ b/apps/desktop/src/themes/presets.ts @@ -0,0 +1,284 @@ +/** + * Built-in desktop themes. Names match the CLI skins / dashboard presets. + * Add new themes here — no code changes needed elsewhere. + */ + +import type { DesktopTheme, DesktopThemeTypography } from './types' + +const SYSTEM_SANS = + '"Segoe WPC", "Segoe UI", -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif' + +const SYSTEM_MONO = '"Cascadia Code", "JetBrains Mono", "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace' + +export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = { fontSans: SYSTEM_SANS, fontMono: SYSTEM_MONO } + +const NOUS_BLUE = '#0053FD' +const PSYCHE_BLUE = '#1540B1' +const PSYCHE_WARM = '#FFE6CB' + +const nousTint = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, #FFFFFF)` +const nousTintTransparent = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, transparent)` + +/** + * Nous — canonical Hermes desktop identity. The palette keeps the current + * glass geometry neutral, then lets the old bb/gui blue and psyche cream + * return as accent seeds. + */ +export const nousTheme: DesktopTheme = { + name: 'nous', + label: 'Nous', + description: 'Glass neutrals with Nous blue accents', + colors: { + background: '#F8FAFF', + foreground: '#17171A', + card: '#FFFFFF', + cardForeground: '#17171A', + muted: nousTint(5), + mutedForeground: '#666678', + popover: '#FFFFFF', + popoverForeground: '#17171A', + primary: NOUS_BLUE, + primaryForeground: '#FCFCFC', + secondary: nousTint(7), + secondaryForeground: '#242432', + accent: nousTint(10), + accentForeground: '#202030', + border: nousTintTransparent(22), + input: nousTintTransparent(30), + ring: NOUS_BLUE, + midground: NOUS_BLUE, + composerRing: NOUS_BLUE, + destructive: '#C72E4D', + destructiveForeground: '#FFFFFF', + sidebarBackground: '#F3F7FF', + sidebarBorder: nousTintTransparent(18), + userBubble: nousTint(6), + userBubbleBorder: nousTintTransparent(24) + }, + darkColors: { + background: '#0D2F86', + foreground: PSYCHE_WARM, + card: '#12378F', + cardForeground: PSYCHE_WARM, + muted: '#183F9A', + mutedForeground: '#B5C7F3', + popover: '#123A96', + popoverForeground: PSYCHE_WARM, + primary: PSYCHE_WARM, + primaryForeground: '#0D2F86', + secondary: '#1B45A4', + secondaryForeground: '#E0E8FF', + accent: PSYCHE_BLUE, + accentForeground: '#F0F4FF', + border: '#3158AD', + input: '#0B2566', + ring: PSYCHE_WARM, + midground: NOUS_BLUE, + composerRing: PSYCHE_WARM, + destructive: '#C0473A', + destructiveForeground: '#FEF2F2', + sidebarBackground: '#09286F', + sidebarBorder: '#234A9C', + userBubble: '#143B91', + userBubbleBorder: '#3A63BD' + }, + typography: { + fontSans: SYSTEM_SANS, + fontMono: `"Courier Prime", ${SYSTEM_MONO}`, + fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap' + } +} + +/** Deep blue-violet with cool accents. Matches the dashboard midnight theme. */ +export const midnightTheme: DesktopTheme = { + name: 'midnight', + label: 'Midnight', + description: 'Deep blue-violet with cool accents', + colors: { + background: '#08081c', + foreground: '#ddd6ff', + card: '#0d0d28', + cardForeground: '#ddd6ff', + muted: '#13133a', + mutedForeground: '#7c7ab0', + popover: '#0f0f2e', + popoverForeground: '#ddd6ff', + primary: '#ddd6ff', + primaryForeground: '#08081c', + secondary: '#1a1a4a', + secondaryForeground: '#c4bff0', + accent: '#1a1a44', + accentForeground: '#d0c8ff', + border: '#1e1e52', + input: '#1e1e52', + ring: '#8b80e8', + midground: '#8b80e8', + destructive: '#b03060', + destructiveForeground: '#fef2f2', + sidebarBackground: '#06061a', + sidebarBorder: '#12123a', + userBubble: '#14143a', + userBubbleBorder: '#242466' + }, + typography: { + fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`, + fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap' + } +} + +/** Warm crimson and bronze — forge vibes. Matches the CLI ares skin. */ +export const emberTheme: DesktopTheme = { + name: 'ember', + label: 'Ember', + description: 'Warm crimson and bronze — forge vibes', + colors: { + background: '#160800', + foreground: '#ffd8b0', + card: '#1e0e04', + cardForeground: '#ffd8b0', + muted: '#2a1408', + mutedForeground: '#aa7a56', + popover: '#221008', + popoverForeground: '#ffd8b0', + primary: '#ffd8b0', + primaryForeground: '#160800', + secondary: '#341800', + secondaryForeground: '#f0c090', + accent: '#301600', + accentForeground: '#e8c080', + border: '#3a1c08', + input: '#3a1c08', + ring: '#d97316', + midground: '#d97316', + destructive: '#c43010', + destructiveForeground: '#fef2f2', + sidebarBackground: '#100600', + sidebarBorder: '#2a1004', + userBubble: '#2a1000', + userBubbleBorder: '#4a2010' + }, + typography: { + fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`, + fontUrl: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap' + } +} + +/** Clean grayscale. Matches the CLI mono skin and dashboard mono theme. */ +export const monoTheme: DesktopTheme = { + name: 'mono', + label: 'Mono', + description: 'Clean grayscale — minimal and focused', + colors: { + background: '#0e0e0e', + foreground: '#eaeaea', + card: '#141414', + cardForeground: '#eaeaea', + muted: '#1e1e1e', + mutedForeground: '#808080', + popover: '#181818', + popoverForeground: '#eaeaea', + primary: '#eaeaea', + primaryForeground: '#0e0e0e', + secondary: '#262626', + secondaryForeground: '#c8c8c8', + accent: '#222222', + accentForeground: '#d8d8d8', + border: '#2a2a2a', + input: '#2a2a2a', + ring: '#9a9a9a', + midground: '#9a9a9a', + destructive: '#a84040', + destructiveForeground: '#fef2f2', + sidebarBackground: '#0a0a0a', + sidebarBorder: '#202020', + userBubble: '#1a1a1a', + userBubbleBorder: '#363636' + } +} + +/** Neon green on black. Matches the CLI cyberpunk skin and dashboard theme. */ +export const cyberpunkTheme: DesktopTheme = { + name: 'cyberpunk', + label: 'Cyberpunk', + description: 'Neon green on black — matrix terminal', + colors: { + background: '#000a00', + foreground: '#00ff41', + card: '#001200', + cardForeground: '#00ff41', + muted: '#001a00', + mutedForeground: '#1a8a30', + popover: '#001000', + popoverForeground: '#00ff41', + primary: '#00ff41', + primaryForeground: '#000a00', + secondary: '#002800', + secondaryForeground: '#00cc34', + accent: '#002000', + accentForeground: '#00e038', + border: '#003000', + input: '#003000', + ring: '#00ff41', + midground: '#00ff41', + destructive: '#ff003c', + destructiveForeground: '#000a00', + sidebarBackground: '#000600', + sidebarBorder: '#001800', + userBubble: '#001400', + userBubbleBorder: '#004800' + }, + typography: { + fontMono: `"Courier New", Courier, monospace`, + fontSans: `"Courier New", Courier, monospace` + } +} + +/** Cool slate blue for developers. Matches the CLI slate skin. */ +export const slateTheme: DesktopTheme = { + name: 'slate', + label: 'Slate', + description: 'Cool slate blue — focused developer theme', + colors: { + background: '#0d1117', + foreground: '#c9d1d9', + card: '#161b22', + cardForeground: '#c9d1d9', + muted: '#21262d', + mutedForeground: '#8b949e', + popover: '#1c2128', + popoverForeground: '#c9d1d9', + primary: '#c9d1d9', + primaryForeground: '#0d1117', + secondary: '#2a3038', + secondaryForeground: '#adb5bf', + accent: '#1e2530', + accentForeground: '#c0c8d0', + border: '#30363d', + input: '#30363d', + ring: '#58a6ff', + midground: '#58a6ff', + destructive: '#cf4848', + destructiveForeground: '#fef2f2', + sidebarBackground: '#090d13', + sidebarBorder: '#1c2228', + userBubble: '#1e2a38', + userBubbleBorder: '#2e4060' + }, + typography: { + fontMono: `"JetBrains Mono", ${SYSTEM_MONO}` + } +} + +export const BUILTIN_THEMES: Record<string, DesktopTheme> = { + nous: nousTheme, + midnight: midnightTheme, + ember: emberTheme, + mono: monoTheme, + cyberpunk: cyberpunkTheme, + slate: slateTheme +} + +export const BUILTIN_THEME_LIST = Object.values(BUILTIN_THEMES) + +/** Skin used when nothing is persisted or the persisted name is retired. */ +export const DEFAULT_SKIN_NAME = 'nous' diff --git a/apps/desktop/src/themes/types.ts b/apps/desktop/src/themes/types.ts new file mode 100644 index 000000000..09bff38ca --- /dev/null +++ b/apps/desktop/src/themes/types.ts @@ -0,0 +1,66 @@ +/** + * Desktop app theme model. + * + * colors — Tailwind color tokens written directly to CSS vars. + * darkColors — optional hand-tuned dark variant (else `colors` is reused + * unchanged for dark, and a synth pass generates light). + * typography — font families + optional stylesheet URL. + * + * Everything else (layout, sizing, radius, line-height) lives in styles.css. + * Add new themes in `presets.ts` — no other code changes needed. + */ + +export interface DesktopThemeColors { + background: string + foreground: string + card: string + cardForeground: string + muted: string + mutedForeground: string + popover: string + popoverForeground: string + primary: string + primaryForeground: string + secondary: string + secondaryForeground: string + accent: string + accentForeground: string + border: string + input: string + /** Generic focus ring — buttons, inputs, etc. */ + ring: string + /** + * Brand-accent stroke — focus rings, streaming cursors, active session + * pills, branded scrollbars, text selection. Falls back to `ring`. + * Aliased to the DS `--midground` token. + */ + midground?: string + /** Auto-derived from `midground` luminance when omitted. */ + midgroundForeground?: string + /** Composer outline / focus color. Falls back to `midground`. */ + composerRing?: string + destructive: string + destructiveForeground: string + sidebarBackground?: string + sidebarBorder?: string + userBubble?: string + userBubbleBorder?: string +} + +export interface DesktopThemeTypography { + fontSans: string + fontMono: string + /** Google/Bunny/self-hosted font stylesheet URL. */ + fontUrl?: string +} + +export interface DesktopTheme { + name: string + label: string + description: string + /** Light palette (also reused for dark when `darkColors` is omitted). */ + colors: DesktopThemeColors + /** Hand-tuned dark palette. Skins like `nous` ship one. */ + darkColors?: DesktopThemeColors + typography?: Partial<DesktopThemeTypography> +} diff --git a/apps/desktop/src/themes/use-skin-command.ts b/apps/desktop/src/themes/use-skin-command.ts new file mode 100644 index 000000000..72af19346 --- /dev/null +++ b/apps/desktop/src/themes/use-skin-command.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react' + +import { useTheme } from './context' + +// Retired skin names land on the canonical Nous skin so old muscle memory works. +const ALIASES: Record<string, string> = { + ares: 'ember', + default: 'nous', + gold: 'nous', + hermes: 'nous', + 'nous-light': 'nous' +} + +export function useSkinCommand() { + const { availableThemes, setTheme, themeName } = useTheme() + + return useCallback( + (rawArg: string) => { + const arg = rawArg.trim() + + if (!availableThemes.length) { + return 'No desktop themes are available.' + } + + const activeIndex = Math.max( + 0, + availableThemes.findIndex(t => t.name === themeName) + ) + + if (!arg || arg === 'next') { + const next = availableThemes[(activeIndex + 1) % availableThemes.length] + setTheme(next.name) + + return `Desktop theme switched to ${next.label}.` + } + + if (arg === 'list' || arg === 'ls' || arg === 'status') { + const rows = availableThemes.map(t => `${t.name === themeName ? '*' : ' '} ${t.name.padEnd(10)} ${t.label}`) + + return ['Desktop themes:', ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n') + } + + const normalized = arg.toLowerCase() + const targetName = ALIASES[normalized] || normalized + + const target = availableThemes.find( + t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized + ) + + if (!target) { + return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}` + } + + setTheme(target.name) + + return `Desktop theme switched to ${target.label}.` + }, + [availableThemes, setTheme, themeName] + ) +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts new file mode 100644 index 000000000..b773c30e2 --- /dev/null +++ b/apps/desktop/src/types/hermes.ts @@ -0,0 +1,568 @@ +export interface ConfigFieldSchema { + category?: string + description?: string + options?: unknown[] + type?: 'boolean' | 'list' | 'number' | 'select' | 'string' | 'text' +} + +export interface ConfigSchemaResponse { + category_order?: string[] + fields: Record<string, ConfigFieldSchema> +} + +export interface AudioTranscriptionResponse { + ok: boolean + provider?: string + transcript: string +} + +export interface AudioSpeakResponse { + ok: boolean + data_url: string + mime_type: string + provider?: string +} + +export interface ElevenLabsVoice { + label: string + name: string + voice_id: string +} + +export interface ElevenLabsVoicesResponse { + available: boolean + voices: ElevenLabsVoice[] +} + +export interface OAuthProviderStatus { + error?: string + expires_at?: null | string + has_refresh_token?: boolean + last_refresh?: null | string + logged_in: boolean + source?: null | string + source_label?: null | string + token_preview?: null | string +} + +export interface OAuthProvider { + cli_command: string + docs_url: string + flow: 'device_code' | 'external' | 'pkce' + id: string + name: string + status: OAuthProviderStatus +} + +export interface OAuthProvidersResponse { + providers: OAuthProvider[] +} + +export type OAuthStartResponse = + | { + auth_url: string + expires_in: number + flow: 'pkce' + session_id: string + } + | { + expires_in: number + flow: 'device_code' + poll_interval: number + session_id: string + user_code: string + verification_url: string + } + +export interface OAuthSubmitResponse { + message?: string + ok: boolean + status: 'approved' | 'error' +} + +export interface OAuthPollResponse { + error_message?: null | string + expires_at?: null | number + session_id: string + status: 'approved' | 'denied' | 'error' | 'expired' | 'pending' +} + +export interface EnvVarInfo { + advanced: boolean + category: string + description: string + is_password: boolean + is_set: boolean + redacted_value: null | string + tools: string[] + url: null | string +} + +export interface MessagingEnvVarInfo { + advanced: boolean + description: string + is_password: boolean + is_set: boolean + key: string + prompt: string + redacted_value: null | string + required: boolean + url: null | string +} + +export interface MessagingHomeChannel { + chat_id: string + name: string + platform: string + thread_id?: string +} + +export interface MessagingPlatformInfo { + configured: boolean + description: string + docs_url: string + enabled: boolean + env_vars: MessagingEnvVarInfo[] + error_code?: null | string + error_message?: null | string + gateway_running: boolean + home_channel?: MessagingHomeChannel | null + id: string + name: string + state?: null | string + updated_at?: null | string +} + +export interface MessagingPlatformsResponse { + platforms: MessagingPlatformInfo[] +} + +export interface MessagingPlatformUpdate { + clear_env?: string[] + enabled?: boolean + env?: Record<string, string> +} + +export interface MessagingPlatformTestResponse { + message: string + ok: boolean + state?: null | string +} + +export interface GatewayReadyPayload { + skin?: unknown +} + +export interface HermesConfig { + agent?: { + reasoning_effort?: string + personalities?: Record<string, unknown> + service_tier?: string + } + display?: { + personality?: string + skin?: string + } + terminal?: { + cwd?: string + } + stt?: { + enabled?: boolean + } + voice?: { + max_recording_seconds?: number + } +} + +export type HermesConfigRecord = Record<string, unknown> + +export interface ModelInfoResponse { + auto_context_length?: number + capabilities?: Record<string, unknown> + config_context_length?: number + effective_context_length?: number + model: string + provider: string +} + +export interface ModelPricing { + /** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */ + input: string + /** Formatted $/Mtok output price. */ + output: string + /** Formatted $/Mtok cached-input price, or null when the model has none. */ + cache: string | null + /** True when the model costs nothing (free tier eligible). */ + free: boolean +} + +export interface ModelOptionProvider { + is_current?: boolean + models?: string[] + name: string + slug: string + total_models?: number + warning?: string + /** Per-model pricing keyed by model id (present when the picker requested + * pricing and the provider supports live pricing). */ + pricing?: Record<string, ModelPricing> + /** Nous only: whether the current account is on the free tier. */ + free_tier?: boolean + /** Nous only: paid models a free-tier user cannot select (shown disabled). */ + unavailable_models?: string[] +} + +export interface ModelOptionsResponse { + model?: string + provider?: string + providers?: ModelOptionProvider[] +} + +export interface PaginatedSessions { + limit: number + offset: number + sessions: SessionInfo[] + total: number +} + +export interface RpcEvent<T = unknown> { + payload?: T + session_id?: string + type: string +} + +export interface SessionCreateResponse { + info?: SessionRuntimeInfo + message_count?: number + messages?: SessionMessage[] + session_id: string + stored_session_id?: string +} + +export interface SessionInfo { + cwd?: null | string + ended_at: null | number + id: string + input_tokens: number + is_active: boolean + last_active: number + message_count: number + model: null | string + output_tokens: number + preview: null | string + source: null | string + started_at: number + title: null | string + tool_call_count: number +} + +export interface SessionMessage { + codex_reasoning_items?: unknown + content: unknown + context?: unknown + name?: string + reasoning?: null | string + reasoning_content?: null | string + reasoning_details?: unknown + role: 'assistant' | 'system' | 'tool' | 'user' + text?: unknown + timestamp?: number + tool_call_id?: null | string + tool_calls?: unknown + tool_name?: string +} + +export interface SessionMessagesResponse { + messages: SessionMessage[] + session_id: string +} + +export interface SessionResumeResponse { + info?: SessionRuntimeInfo + message_count: number + messages: SessionMessage[] + resumed: string + session_id: string +} + +export interface SessionRuntimeInfo { + branch?: string + config_warning?: string + credential_warning?: string + cwd?: string + desktop_contract?: number + fast?: boolean + model?: string + personality?: string + provider?: string + reasoning_effort?: string + running?: boolean + service_tier?: string + skills?: Record<string, string[]> | string[] + tools?: Record<string, string[]> + usage?: Partial<UsageStats> + version?: string +} + +export interface UsageStats { + calls: number + context_max?: number + context_percent?: number + context_used?: number + cost_usd?: number + input: number + output: number + total: number +} + +export interface AnalyticsDailyEntry { + actual_cost: number + api_calls: number + cache_read_tokens: number + day: string + estimated_cost: number + input_tokens: number + output_tokens: number + reasoning_tokens: number + sessions: number +} + +export interface AnalyticsModelEntry { + api_calls: number + estimated_cost: number + input_tokens: number + model: string + output_tokens: number + sessions: number +} + +export interface AnalyticsResponse { + by_model: AnalyticsModelEntry[] + daily: AnalyticsDailyEntry[] + period_days: number + skills: { + summary: AnalyticsSkillsSummary + top_skills: AnalyticsSkillEntry[] + } + totals: AnalyticsTotals +} + +export interface AnalyticsSkillEntry { + last_used_at: null | number + manage_count: number + percentage: number + skill: string + total_count: number + view_count: number +} + +export interface AnalyticsSkillsSummary { + distinct_skills_used: number + total_skill_actions: number + total_skill_edits: number + total_skill_loads: number +} + +export interface AnalyticsTotals { + total_actual_cost: number + total_api_calls: null | number + total_cache_read: null | number + total_estimated_cost: number + total_input: null | number + total_output: null | number + total_reasoning: null | number + total_sessions: number +} + +export interface CronJob { + deliver?: null | string + enabled: boolean + id: string + last_error?: null | string + last_run_at?: null | string + name?: null | string + next_run_at?: null | string + prompt?: null | string + schedule?: CronJobSchedule + schedule_display?: null | string + script?: null | string + state?: null | string +} + +export interface CronJobCreatePayload { + deliver?: string + name?: string + prompt: string + schedule: string +} + +export interface CronJobSchedule { + display?: string + expr?: string + kind?: string +} + +export interface CronJobUpdates { + deliver?: string + enabled?: boolean + name?: string + prompt?: string + schedule?: string +} + +export interface ProfileCreatePayload { + clone_from_default?: boolean + name: string + no_skills?: boolean +} + +export interface ProfileInfo { + has_env: boolean + is_default: boolean + model: null | string + name: string + path: string + provider: null | string + skill_count: number +} + +export interface ProfileSetupCommand { + command: string +} + +export interface ProfileSoul { + content: string + exists: boolean +} + +export interface ProfilesResponse { + profiles: ProfileInfo[] +} + +export interface SkillInfo { + category: string + description: string + enabled: boolean + name: string +} + +export interface ToolsetInfo { + configured: boolean + description: string + enabled: boolean + label: string + name: string + tools: string[] +} + +export interface ToolEnvVar { + key: string + prompt: string + url: string | null + default: string | null + is_set: boolean +} + +export interface ToolProvider { + name: string + badge: string + tag: string + env_vars: ToolEnvVar[] + post_setup: string | null + requires_nous_auth: boolean +} + +export interface ToolsetConfig { + name: string + has_category: boolean + providers: ToolProvider[] +} + +export interface SessionSearchResult { + model: string | null + role: string | null + session_id: string + session_started: number | null + snippet: string + source: string | null +} + +export interface SessionSearchResponse { + results: SessionSearchResult[] +} + +export interface LogsResponse { + file: string + lines: string[] +} + +export interface PlatformStatus { + error_code?: string + error_message?: string + state: string + updated_at: string +} + +export interface StatusResponse { + active_sessions: number + config_path: string + config_version: number + env_path: string + gateway_exit_reason: string | null + gateway_health_url: string | null + gateway_pid: number | null + gateway_platforms: Record<string, PlatformStatus> + gateway_running: boolean + gateway_state: string | null + gateway_updated_at: string | null + hermes_home: string + latest_config_version: number + release_date: string + version: string +} + +export interface ActionResponse { + name: string + ok: boolean + pid: number +} + +export interface ActionStatusResponse { + exit_code: number | null + lines: string[] + name: string + pid: number | null + running: boolean +} + +export interface AuxiliaryTaskAssignment { + base_url: string + model: string + provider: string + task: string +} + +export interface AuxiliaryModelsResponse { + main: { model: string; provider: string } + tasks: AuxiliaryTaskAssignment[] +} + +export interface ModelAssignmentRequest { + model: string + provider: string + scope: 'main' | 'auxiliary' + task?: string +} + +export interface ModelAssignmentResponse { + /** Toolset keys auto-routed through the Nous Tool Gateway as a result of + * switching the main provider to Nous. Empty unless provider === 'nous' + * and the user is a paid subscriber with unconfigured tools. */ + gateway_tools?: string[] + model?: string + ok: boolean + provider?: string + reset?: boolean + scope?: string + tasks?: string[] +} diff --git a/apps/desktop/src/vite-env.d.ts b/apps/desktop/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/desktop/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 000000000..e66bb7aa3 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + "@hermes/shared": ["../shared/src/index.ts"] + } + }, + "include": ["src", "../shared/src"], + "references": [] +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts new file mode 100644 index 000000000..ec5784dc7 --- /dev/null +++ b/apps/desktop/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import path from 'path' + +export default defineConfig({ + base: './', + plugins: [react(), tailwindcss()], + build: { + // Keep desktop packaging stable: Shiki ships many dynamic chunks by + // default, and electron-builder can OOM scanning thousands of files. + rolldownOptions: { + output: { + codeSplitting: false + } + } + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@hermes/shared': path.resolve(__dirname, '../shared/src'), + react: path.resolve(__dirname, '../../node_modules/react'), + 'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'), + 'react/jsx-dev-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'), + 'react/jsx-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js') + }, + dedupe: ['react', 'react-dom'] + }, + server: { + host: '127.0.0.1', + port: 5174, + strictPort: true + }, + preview: { + host: '127.0.0.1', + port: 4174 + } +}) diff --git a/apps/shared/package.json b/apps/shared/package.json new file mode 100644 index 000000000..bf9baa8f3 --- /dev/null +++ b/apps/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "@hermes/shared", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "types": "./src/index.ts", + "scripts": { + "type-check": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts new file mode 100644 index 000000000..3a900ee48 --- /dev/null +++ b/apps/shared/src/index.ts @@ -0,0 +1,10 @@ +export { + JsonRpcGatewayClient, + type ConnectionState, + type GatewayClientOptions, + type GatewayEvent, + type GatewayEventName, + type GatewayRequestId, + type JsonRpcFrame, + type WebSocketLike +} from './json-rpc-gateway' diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts new file mode 100644 index 000000000..bbf4b3abd --- /dev/null +++ b/apps/shared/src/json-rpc-gateway.ts @@ -0,0 +1,275 @@ +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' + | 'error' + | 'skin.changed' + | (string & {}) + +export interface GatewayEvent<P = unknown> { + payload?: P + session_id?: string + type: GatewayEventName +} + +export type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed' | 'error' +export type GatewayRequestId = number | string + +export interface JsonRpcFrame { + error?: { message?: string } + id?: GatewayRequestId | null + method?: string + params?: GatewayEvent + result?: unknown +} + +export type WebSocketLike = WebSocket + +type PendingCall = { + reject: (error: Error) => void + resolve: (value: unknown) => void + timer?: ReturnType<typeof setTimeout> +} + +export interface GatewayClientOptions { + closedErrorMessage?: string + connectErrorMessage?: string + createRequestId?: (nextId: number) => GatewayRequestId + requestIdPrefix?: string + requestTimeoutMs?: number + socketFactory?: (url: string) => WebSocketLike + notConnectedErrorMessage?: string +} + +const ANY = '*' +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000 + +export class JsonRpcGatewayClient { + private nextId = 0 + private pending = new Map<GatewayRequestId, PendingCall>() + private socket: WebSocketLike | null = null + private state: ConnectionState = 'idle' + private readonly eventHandlers = new Map<string, Set<(event: GatewayEvent) => void>>() + private readonly stateHandlers = new Set<(state: ConnectionState) => void>() + private readonly options: Required<Omit<GatewayClientOptions, 'socketFactory'>> & + Pick<GatewayClientOptions, 'socketFactory'> + + constructor(options: GatewayClientOptions = {}) { + this.options = { + closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed', + connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed', + createRequestId: + options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`), + notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected', + requestIdPrefix: options.requestIdPrefix ?? 'r', + requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + socketFactory: options.socketFactory + } + } + + get connectionState(): ConnectionState { + return this.state + } + + async connect(wsUrl: string): Promise<void> { + if (this.socket?.readyState === WebSocket.OPEN || this.state === 'connecting') { + return + } + + this.setState('connecting') + + const socket = this.options.socketFactory?.(wsUrl) ?? new WebSocket(wsUrl) + this.socket = socket + + socket.addEventListener('message', message => { + this.handleMessage(message.data) + }) + + socket.addEventListener('close', () => { + this.setState('closed') + this.rejectAllPending(new Error(this.options.closedErrorMessage)) + }) + + await new Promise<void>((resolve, reject) => { + const onOpen = () => { + socket.removeEventListener('error', onError) + this.setState('open') + resolve() + } + + const onError = () => { + socket.removeEventListener('open', onOpen) + this.setState('error') + reject(new Error(this.options.connectErrorMessage)) + } + + socket.addEventListener('open', onOpen, { once: true }) + socket.addEventListener('error', onError, { once: true }) + }) + } + + close(): void { + this.socket?.close() + this.socket = null + } + + on<P = unknown>(type: GatewayEventName, handler: (event: GatewayEvent<P>) => void): () => void { + let handlers = this.eventHandlers.get(type) + + if (!handlers) { + handlers = new Set() + this.eventHandlers.set(type, handlers) + } + + handlers.add(handler as (event: GatewayEvent) => void) + + return () => handlers?.delete(handler as (event: GatewayEvent) => void) + } + + onAny(handler: (event: GatewayEvent) => void): () => void { + return this.on(ANY as GatewayEventName, handler) + } + + onEvent(handler: (event: GatewayEvent) => void): () => void { + return this.onAny(handler) + } + + onState(handler: (state: ConnectionState) => void): () => void { + this.stateHandlers.add(handler) + handler(this.state) + + return () => this.stateHandlers.delete(handler) + } + + request<T>(method: string, params: Record<string, unknown> = {}, timeoutMs = this.options.requestTimeoutMs): Promise<T> { + const socket = this.socket + + if (!socket || socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error(this.options.notConnectedErrorMessage)) + } + + const id = this.options.createRequestId(++this.nextId) + + return new Promise<T>((resolve, reject) => { + const pending: PendingCall = { + reject, + resolve: value => resolve(value as T) + } + + if (timeoutMs > 0) { + pending.timer = setTimeout(() => { + if (this.pending.delete(id)) { + reject(new Error(`request timed out: ${method}`)) + } + }, timeoutMs) + } + + this.pending.set(id, pending) + + try { + socket.send( + JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params + }) + ) + } catch (error) { + this.clearPending(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + private handleMessage(raw: unknown): void { + const text = typeof raw === 'string' ? raw : String(raw) + let frame: JsonRpcFrame + + try { + frame = JSON.parse(text) as JsonRpcFrame + } catch { + return + } + + if (frame.id !== undefined && frame.id !== null) { + const call = this.pending.get(frame.id) + + if (!call) { + return + } + + this.clearPending(frame.id) + + if (frame.error) { + call.reject(new Error(frame.error.message || 'Hermes RPC failed')) + } else { + call.resolve(frame.result) + } + + return + } + + if (frame.method === 'event' && frame.params?.type) { + this.dispatchEvent(frame.params) + } + } + + private clearPending(id: GatewayRequestId): void { + const call = this.pending.get(id) + + if (call?.timer) { + clearTimeout(call.timer) + } + + this.pending.delete(id) + } + + private dispatchEvent(event: GatewayEvent): void { + for (const handler of this.eventHandlers.get(event.type) ?? []) { + handler(event) + } + + for (const handler of this.eventHandlers.get(ANY) ?? []) { + handler(event) + } + } + + private rejectAllPending(error: Error): void { + for (const [id, call] of this.pending) { + if (call.timer) { + clearTimeout(call.timer) + } + + call.reject(error) + this.pending.delete(id) + } + } + + private setState(state: ConnectionState): void { + if (this.state === state) { + return + } + + this.state = state + + for (const handler of this.stateHandlers) { + handler(state) + } + } +} diff --git a/apps/shared/tsconfig.json b/apps/shared/tsconfig.json new file mode 100644 index 000000000..4e530c70d --- /dev/null +++ b/apps/shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/cli.py b/cli.py index e6bd71674..c14c71884 100644 --- a/cli.py +++ b/cli.py @@ -3563,6 +3563,7 @@ class HermesCLI: except Exception: pass + if not agent: return snapshot @@ -6020,24 +6021,6 @@ class HermesCLI: f"Tokens: {total_tokens:,}", f"Agent Running: {'Yes' if is_running else 'No'}", ]) - - # Session recap — pure local compute summary of recent activity - # (turn counts, tools used, files touched, last ask, last reply). - # No LLM call, no prompt-cache impact. Inspired by Claude Code - # 2.1.114's /recap. - try: - from hermes_cli.session_recap import build_recap - recap = build_recap( - self.conversation_history or [], - session_title=title or None, - session_id=self.session_id, - platform="cli", - ) - if recap: - lines.extend(["", recap]) - except Exception as exc: # defensive — don't let /status fail - logger.debug("build_recap failed in /status: %s", exc) - self._console_print("\n".join(lines), highlight=False, markup=False) def _fast_command_available(self) -> bool: diff --git a/docker/cont-init.d/02-reconcile-profiles b/docker/cont-init.d/02-reconcile-profiles index 98b1f59ee..5050a24f1 100755 --- a/docker/cont-init.d/02-reconcile-profiles +++ b/docker/cont-init.d/02-reconcile-profiles @@ -43,4 +43,3 @@ if [ -d /run/service/.s6-svscan ]; then fi exec s6-setuidgid hermes /opt/hermes/.venv/bin/python -m hermes_cli.container_boot - diff --git a/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md b/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md deleted file mode 100644 index 43c0e5da7..000000000 --- a/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md +++ /dev/null @@ -1,473 +0,0 @@ -# Telegram DM User-Managed Multi-Session Topics Implementation Plan - -> **For Hermes:** Use test-driven-development for implementation. Use subagent-driven-development only after this plan is split into small reviewed tasks. - -**Goal:** Add an opt-in Telegram DM multi-session mode where Telegram user-created private-chat topics become independent Hermes session lanes, while the root DM becomes a system lobby. - -**Architecture:** Rely on Telegram's native private-chat topic UI. Users create new topics with the `+` button; Hermes maps each `message_thread_id` to a separate session lane. Hermes does not create topics for normal `/new` flow and does not try to manage topic lifecycle beyond activation/status, root-lobby behavior, and restoring legacy sessions into a user-created topic. - -**Tech Stack:** Hermes gateway, Telegram Bot API 9.4+, python-telegram-bot adapter, SQLite SessionDB / side tables, pytest. - ---- - -## 1. Product decisions - -### Accepted - -- PR-quality implementation: migrations, tests, docs, backwards compatibility. -- Use SQLite persistence, not JSON sidecars. -- Live status suffixes in topic titles are out of MVP. -- Topic title sync/editing is out of MVP except future-compatible storage if cheap. -- User creates Telegram topics manually through the Telegram bot interface. -- `/new` does **not** create Telegram topics. -- Root/main DM becomes a system lobby after activation. -- Existing Telegram behavior remains unchanged until the feature is activated/enabled. -- Migration of old sessions is supported through `/topic` listing and `/topic <session_id>` restore inside a user-created topic. - -### Telegram API assumptions verified from Bot API docs - -- `getMe` returns bot `User` fields: - - `has_topics_enabled`: forum/topic mode enabled in private chats. - - `allows_users_to_create_topics`: users may create/delete topics in private chats. -- `createForumTopic` works for private chats with a user, but MVP does not rely on it for normal flow. -- `Message.message_thread_id` identifies a topic in private chats. -- `sendMessage` supports `message_thread_id` for private-chat topics. -- `pinChatMessage` is allowed in private chats. - ---- - -## 2. Target UX - -### 2.1 Activation from root/main DM - -User sends: - -```text -/topic -``` - -Hermes: - -1. calls Telegram `getMe`; -2. verifies `has_topics_enabled` and `allows_users_to_create_topics`; -3. enables multi-session topic mode for this Telegram DM user/chat; -4. sends an onboarding message; -5. pins the onboarding message if configured; -6. shows old/unlinked sessions that can be restored into topics. - -Suggested onboarding text: - -```text -Multi-session mode is enabled. - -Create new Hermes chats with the + button in this bot interface. Each Telegram topic is an independent Hermes session, so you can work on different tasks in parallel. - -This main chat is reserved for system commands, status, and session management. - -To restore an old session: -1. Use /topic here to see unlinked sessions. -2. Create a new topic with the + button. -3. Send /topic <session_id> inside that topic. -``` - -### 2.2 Root/main DM after activation - -Root DM is a system lobby. - -Allowed/system commands include at least: - -- `/topic` -- `/status` -- `/sessions` if available -- `/usage` -- `/help` -- `/platforms` - -Normal user prompts in root DM do not enter the agent loop. Reply: - -```text -This main chat is reserved for system commands. - -To chat with Hermes, create a new topic using the + button in this bot interface. Each topic works as an independent Hermes session. -``` - -`/new` in root DM does not create a session/topic. Reply: - -```text -To start a new parallel Hermes chat, create a new topic with the + button in this bot interface. - -Each topic is an independent Hermes session. Use /new inside a topic only if you want to replace that topic's current session. -``` - -### 2.3 First message in a user-created topic - -When a user creates a Telegram topic and sends the first message there: - -1. Hermes receives a Telegram DM message with `message_thread_id`. -2. Hermes derives the existing thread-aware `session_key` from `(platform=telegram, chat_type=dm, chat_id, thread_id)`. -3. If no binding exists, Hermes creates a fresh Hermes session for this topic lane and persists the binding. -4. The message runs through the normal agent loop for that lane. - -### 2.4 `/new` inside a non-main topic - -`/new` remains supported but replaces the session attached to the current topic lane. - -Hermes should warn: - -```text -Started a new Hermes session in this topic. - -Tip: for parallel work, create a new topic with the + button instead of using /new here. /new replaces the session attached to the current topic. -``` - -### 2.5 `/topic` in root/main DM after activation - -Shows: - -- mode enabled/disabled; -- last capability check result; -- whether intro message is pinned if known; -- count of known topic bindings; -- list of old/unlinked sessions. - -Example: - -```text -Telegram multi-session topics are enabled. - -Create new Hermes chats with the + button in this bot interface. - -Unlinked previous sessions: -1. 2026-05-01 Research notes — id: abc123 -2. 2026-04-30 Deploy debugging — id: def456 -3. Untitled session — id: ghi789 - -To restore one: -1. Create a new topic with the + button. -2. Open that topic. -3. Send /topic <id> -``` - -### 2.6 `/topic` inside a non-main topic - -Without args, show the current topic binding: - -```text -This topic is linked to: -Session: Research notes -ID: abc123 - -Use /new to replace this topic with a fresh session. -For parallel work, create another topic with the + button. -``` - -### 2.7 `/topic <session_id>` inside a non-main topic - -Restore an old/unlinked session into the current user-created topic. - -Behavior: - -1. reject if not in Telegram DM topic; -2. verify session belongs to the same Telegram user/chat or is a safe legacy root DM session for this user; -3. reject if session is already linked to another active topic in MVP; -4. `SessionStore.switch_session(current_topic_session_key, target_session_id)`; -5. upsert binding with `managed_mode = restored`; -6. send two messages into the topic: - - session restored confirmation; - - last Hermes assistant message if available. - -Example: - -```text -Session restored: Research notes - -Last Hermes message: -... -``` - ---- - -## 3. Persistence model - -Use SQLite, but topic-mode schema changes are **explicit opt-in migrations**, not automatic startup reconciliation. - -Important rollback-safety rule: - -- upgrading Hermes and starting the gateway must not create Telegram topic-mode tables or columns; -- old/default Telegram behavior must keep working on the existing `state.db`; -- the first `/topic` activation path calls an idempotent explicit migration, then enables topic mode for that chat; -- if activation fails before the migration is needed, the database remains in the pre-topic-mode shape. - -### 3.1 No eager `sessions` table mutation for MVP - -Do **not** add `chat_id`, `chat_type`, `thread_id`, or `session_key` columns to `sessions` as part of ordinary `SessionDB()` startup. The existing declarative `_reconcile_columns()` mechanism would add them eagerly on every process start, which violates the managed-migration requirement. - -For MVP, keep origin/session-lane data in topic-specific side tables created only by the explicit `/topic` migration. Legacy unlinked sessions can be discovered conservatively from existing data (`source = telegram`, `user_id = current Telegram user`) plus absence from topic bindings. - -If future PRs need richer origin metadata for all gateway sessions, introduce it behind a separate explicit migration/command or a compatibility-reviewed schema bump. - -### 3.2 Explicit `/topic` migration API - -Add an idempotent method such as: - -```python -def apply_telegram_topic_migration(self) -> None: ... -``` - -It creates only topic-mode side tables/indexes and records: - -```text -state_meta.telegram_dm_topic_schema_version = 1 -``` - -This method is called from `/topic` activation/status paths before reading or writing topic-mode state. It is not called from generic `SessionDB.__init__`, gateway startup, CLI startup, or auto-maintenance. - -### 3.3 `telegram_dm_topic_mode` - -Stores per-user/chat activation state. Created only by `apply_telegram_topic_migration()`. - -Suggested fields: - -- `chat_id` primary key -- `user_id` -- `enabled` -- `activated_at` -- `updated_at` -- `has_topics_enabled` -- `allows_users_to_create_topics` -- `capability_checked_at` -- `intro_message_id` -- `pinned_message_id` - -### 3.4 `telegram_dm_topic_bindings` - -Stores Telegram topic/thread to Hermes session binding. Created only by `apply_telegram_topic_migration()`. - -Suggested fields: - -- `chat_id` -- `thread_id` -- `user_id` -- `session_key` -- `session_id` -- `managed_mode` - - `auto` - - `restored` - - `new_replaced` -- `linked_at` -- `updated_at` - -Recommended constraints: - -- primary key `(chat_id, thread_id)`; -- unique index on `session_id` for MVP to prevent one session linked to multiple topics; -- index `(user_id, chat_id)` for status/listing. - -### 3.5 Unlinked session semantics - -For MVP, a session is unlinked if: - -- `source = telegram`; -- `user_id = current Telegram user`; -- no row in `telegram_dm_topic_bindings` has `session_id = session_id`. - -This is intentionally conservative until a future explicit migration adds richer cross-platform origin metadata. - -Never dedupe by title. - ---- - -## 4. Config - -Suggested config block: - -```yaml -platforms: - telegram: - extra: - multisession_topics: - enabled: false - mode: user_managed_topics - root_chat_behavior: system_lobby - pin_intro_message: true -``` - -Notes: - -- `enabled: false` means existing Telegram behavior is unchanged. -- Activation via `/topic` may create per-chat enabled state only if global config permits it. -- `root_chat_behavior: system_lobby` is the MVP behavior for activated chats. - ---- - -## 5. Command behavior summary - -### `/topic` root/main DM - -- If not activated: capability check, activate, send/pin onboarding, list unlinked sessions. -- If activated: show status and unlinked sessions. - -### `/topic` non-main topic - -- Show current binding. - -### `/topic <session_id>` root/main DM - -Reject with instructions: - -```text -Create a new topic with the + button, open it, then send /topic <session_id> there to restore this session. -``` - -### `/topic <session_id>` non-main topic - -Restore that session into this topic if ownership/linking checks pass. - -### `/new` root/main DM when activated - -Reply with instructions to use the `+` button. Do not enter agent loop. - -### `/new` non-main topic - -Create a new session in the current topic lane, persist/update binding, warn that `+` is preferred for parallel work. - -### Normal text root/main DM when activated - -Reply with system-lobby instruction. Do not enter agent loop. - -### Normal text non-main topic - -Normal Hermes agent flow for that topic's session lane. - ---- - -## 6. PR breakdown - -### PR 1 — Explicit topic-mode schema migration - -**Goal:** Add rollback-safe SQLite support for Telegram topic mode without mutating `state.db` on ordinary upgrade/startup. - -**Files likely touched:** - -- `hermes_state.py` -- tests under `tests/` - -**Tests first:** - -1. opening an old/current DB with `SessionDB()` does not create topic-mode tables or `sessions` origin columns; -2. calling `apply_telegram_topic_migration()` creates `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` idempotently; -3. migration records `state_meta.telegram_dm_topic_schema_version = 1`. - -### PR 2 — Topic mode activation and binding APIs - -**Goal:** Add SQLite persistence for activation and topic bindings. - -**Tests first:** - -1. enable/check mode row round-trips; -2. binding upsert and lookup by `(chat_id, user_id, thread_id)`; -3. linked sessions are excluded from unlinked list. - -### PR 3 — `/topic` activation/status command - -**Goal:** Implement root activation/status/listing behavior. - -**Tests first:** - -1. `/topic` in root checks `getMe` capabilities and records activation; -2. capability failure returns readable instructions; -3. activated root `/topic` lists unlinked sessions. - -### PR 4 — System lobby behavior - -**Goal:** Prevent root chat from entering agent loop after activation. - -**Tests first:** - -1. normal text in activated root returns lobby instruction; -2. `/new` in activated root returns `+` button instruction; -3. non-activated root behavior is unchanged. - -### PR 5 — Auto-bind user-created topics - -**Goal:** First message in non-main topic creates/uses an independent session lane. - -**Tests first:** - -1. new topic message creates binding with `auto_created`; -2. repeated topic message reuses same binding/lane; -3. two topics in same DM do not share sessions. - -### PR 6 — Restore legacy sessions into a topic - -**Goal:** Implement `/topic <session_id>` in non-main topics. - -**Tests first:** - -1. root `/topic <id>` rejects with instructions; -2. topic `/topic <id>` switches current topic lane to target session; -3. restore rejects sessions from other users/chats; -4. restore rejects already-linked sessions; -5. restore emits confirmation and last Hermes assistant message. - -### PR 7 — `/new` inside topic updates binding - -**Goal:** Keep existing `/new` semantics but persist topic binding replacement. - -**Tests first:** - -1. `/new` in topic creates a new session for same topic lane; -2. binding updates to `managed_mode = new_replaced`; -3. response includes guidance to use `+` for parallel work. - -### PR 8 — Docs and polish - -**Goal:** Document the feature and Telegram setup. - -**Files likely touched:** - -- `website/docs/user-guide/messaging/telegram.md` -- maybe `website/docs/user-guide/sessions.md` - -Docs must explain: - -- BotFather/Telegram settings for topic mode and user-created topics; -- `/topic` activation; -- root system lobby; -- using `+` for new parallel chats; -- restoring old sessions with `/topic <id>` inside a topic; -- limitations. - ---- - -## 7. Testing / quality gates - -Run targeted tests after each TDD cycle, then broader tests before completion. - -Suggested commands after inspection confirms test paths: - -```bash -python -m pytest tests/test_hermes_state.py -q -python -m pytest tests/gateway/ -q -python -m pytest tests/ -o 'addopts=' -q -``` - -Do not ship without verifying disabled-feature backwards compatibility. - ---- - -## 8. Definition of done for MVP - -- `/topic` activates/checks Telegram DM multi-session mode. -- Root DM becomes a system lobby after activation. -- Onboarding message tells users to create new chats with the Telegram `+` button. -- Onboarding message can be pinned in private chat. -- User-created topics automatically become independent Hermes session lanes. -- `/new` in root gives instructions, not a new agent run. -- `/new` in a topic creates a new session in that topic and warns that `+` is preferred for parallel work. -- `/topic` in root lists unlinked old sessions. -- `/topic <session_id>` inside a topic restores that session and sends confirmation + last Hermes assistant message. -- Ownership checks prevent restoring other users' sessions. -- Already-linked sessions are not restored into a second topic in MVP. -- Existing Telegram behavior is unchanged when the feature is disabled. -- Tests and docs are included. diff --git a/docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md b/docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md deleted file mode 100644 index 1f00dc94b..000000000 --- a/docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md +++ /dev/null @@ -1,434 +0,0 @@ -# s6-overlay Supervision for Per-Profile Gateways in Docker — Implementation Plan - -> **Status: shipped.** Phases 0–5 landed via PR -> [NousResearch/hermes-agent#30136](https://github.com/NousResearch/hermes-agent/pull/30136) -> in May 2026. This document is preserved as a post-implementation reference -> for the architecture and the resolved design questions. The phase-by-phase -> TDD walkthrough (≈2,800 lines) and the v2/v3 re-validation preambles have -> been removed — the canonical implementation history is the PR commit log -> (`git log --oneline a957ef083..a6f7171a5 -- 'docker/*' 'hermes_cli/service_manager.py' …`). -> Open Questions are collapsed into a single Decision Log table; full -> deliberations live in PR review comments. - -**Goal:** Replace `tini` with s6-overlay as PID 1 in the Hermes Docker image so -that the main hermes process, the dashboard, and dynamically-created -per-profile gateways all run as supervised services (auto-restart on crash, -clean shutdown, signal forwarding, zombie reaping). Preserve every existing -`docker run …` invocation pattern — including interactive TUI. - -**Architecture:** s6-overlay's `/init` is the container ENTRYPOINT, running -s6-svscan as PID 1. Main hermes and the dashboard are declared as static -s6-rc services at image build time. Per-profile gateways — which users create -*after* the image is built (`hermes profile create coder` → -`coder gateway start`) — are registered dynamically by writing service -directories under a scandir watched by s6-svscan. A `ServiceManager` protocol -abstracts the install/start/stop/restart surface across the init systems we -care about (systemd on Linux host, launchd on macOS host, Scheduled Tasks on -native Windows host, s6 inside container) and adds a second tier for runtime -service registration that only s6 implements. - -**Tech Stack:** - -- [s6-overlay](https://github.com/just-containers/s6-overlay) v3.2.3.0 - (noarch + per-arch tarballs ~15 MB). SHA256-pinned via build ARGs; - multi-arch via `TARGETARCH` (amd64 → `x86_64`, arm64 → `aarch64`). -- Debian 13.4 base image (unchanged). -- [hadolint](https://github.com/hadolint/hadolint) for the Dockerfile + - [shellcheck](https://github.com/koalaman/shellcheck) for entrypoint scripts. -- Python subprocess wrappers for `s6-svc`, `s6-svstat`, `s6-svscanctl`. -- Existing systemd/launchd/windows surface in `hermes_cli/gateway.py` and - `hermes_cli/gateway_windows.py`. - -**Scope:** - -- Container-only (host-side systemd/launchd/windows behavior is preserved, - not modified). -- s6-overlay only (no pure-Python fallback). -- Architecture A (s6 owns PID 1; tini is removed). -- Interactive TUI must keep working: - `docker run -it --rm nousresearch/hermes-agent:latest --tui`. -- Dynamic registration is limited to per-profile gateways — one service per - profile, created when a profile is created, torn down when deleted. A - `gateway-default` slot is always registered for the root HERMES_HOME - profile so `hermes gateway start` (no `-p`) has somewhere to land. - -**Out of scope:** - -- Host-side dynamic supervision (systemd-run / launchd transient plists) — - not needed. -- Pure-Python supervisor fallback — not needed. -- Arbitrary user-defined supervised processes inside the container — only - profile gateways. -- Migration of existing per-profile systemd unit generation to s6 on the - host side. -- Non-Docker container runtimes (Podman rootless validated reactively). -- UX polish around in-container profile lifecycle (e.g. a nice status view - of all supervised profile gateways) — deferred to follow-up. - ---- - -## Background From The Codebase - -> **Note on line numbers:** This section refers to functions and structures -> by name only. Use `grep -n 'def <name>' <file>` to locate anything below -> if you need the current line. - -### Pre-s6 container init (what we replaced) - -The original `Dockerfile` declared -`ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/docker/entrypoint.sh" ]`. -tini was PID 1, reaped zombies, forwarded SIGTERM to the process group. The -old `docker/entrypoint.sh`: - -1. `gosu` privilege drop from root → `hermes` UID. -2. Copied `.env.example`, `cli-config.yaml.example`, `SOUL.md` into - `$HERMES_HOME` if missing. -3. Synced bundled skills via `tools/skills_sync.py`. -4. Optionally backgrounded `hermes dashboard` in a subshell when - `HERMES_DASHBOARD=1` — **not supervised**, no restart. -5. `exec hermes "$@"` — tini's sole direct child. - -Known limitations: dashboard crash → stays dead; dashboard fails at startup → -silent; gateway crash → dashboard dies too. The May 4, 2026 decision was -"leave as is" because nothing in the container needed supervision then. -Adding per-profile gateway supervision changed that. - -### ServiceManager surface (what we wrapped, not refactored) - -All init-system logic lives in **`hermes_cli/gateway.py`** (~5,400 LOC at -re-validation). The systemd/launchd code is ~1,500 lines of that, plus a -separate **`hermes_cli/gateway_windows.py`** (~690 LOC) for Windows -Scheduled Tasks. - -| Layer | Systemd functions | Launchd functions | Windows functions | -|---|---|---|---| -| **Detection** | `supports_systemd_services()`, `_systemd_operational()`, `_wsl_systemd_operational()`, `_container_systemd_operational()` | `is_macos()` | `is_windows()`, `gateway_windows.is_installed()` | -| **Paths** | `get_systemd_unit_path(system)`, `get_service_name()` | `get_launchd_plist_path()`, `get_launchd_label()` | `gateway_windows.get_task_name()`, `get_task_script_path()`, `get_startup_entry_path()` | -| **Install/lifecycle** | `systemd_install(force, system, run_as_user)`, `systemd_uninstall(system)`, `systemd_start/stop/restart(system)` | `launchd_install(force)`, `launchd_uninstall/start/stop/restart` | `gateway_windows.install/uninstall/start/stop/restart` | -| **Probes** | `_probe_systemd_service_running(system)`, `_read_systemd_unit_properties(system)`, `_wait_for_systemd_service_restart`, `_recover_pending_systemd_restart` | `_probe_launchd_service_running()` | `gateway_windows.is_task_registered()`, `_pid_exists` helper | -| **D-Bus plumbing** | `_ensure_user_systemd_env`, `_user_systemd_socket_ready`, `_user_systemd_private_socket_path`, `get_systemd_linger_status` | — | — | -| **Unit/plist generation** | `generate_systemd_unit(system, run_as_user)`, `systemd_unit_is_current`, `refresh_systemd_unit_if_needed` | plist templating in `launchd_install` | `_build_gateway_cmd_script`, `_build_startup_launcher`, `_write_task_script` | - -Container-relevant callers outside `gateway.py`: - -- `hermes_cli/status.py` — gained an `s6` branch for in-container runs. -- `hermes_cli/profiles.py` — `create_profile` / `delete_profile` register and - unregister with s6 inside the container (no-op on host). -- `hermes_cli/doctor.py` — `_check_gateway_service_linger` skips on s6, and a - new "Service Supervisor" section reports main-hermes / dashboard / - profile-gateway counts via the ServiceManager. -- `hermes_cli/gateway.py::gateway_command` — the - `elif is_container():` rejection arms that refused gateway lifecycle - operations were removed; the `_dispatch_via_service_manager_if_s6` helper - intercepts start/stop/restart and routes them through s6. - -### Per-profile gateway spawning - -`hermes gateway start`, `coder gateway start` (profile alias), and -`hermes -p <profile> gateway start` all spawn a gateway process scoped to a -given profile. See -[Profiles: Running Gateways](https://hermes-agent.nousresearch.com/docs/user-guide/profiles#running-gateways). -On host, lifecycle is managed via per-profile systemd units -(`hermes-gateway-<profile>.service`); inside the container, an s6 service at -`/run/service/gateway-<name>/` is registered when the profile is created and -torn down when it's deleted. - -**Persistence across container restart:** `/run/service/` is tmpfs — -service registrations are wiped when the container restarts. Profile -directories at `/opt/data/profiles/<name>/` live on the persistent VOLUME, -and each one records its gateway's last state in `gateway_state.json`. -`/etc/cont-init.d/02-reconcile-profiles` walks the persistent profiles on -every container boot, recreates the s6 service slots via -`hermes_cli/container_boot.py`, and auto-starts those whose last recorded -state was `running`. Profiles whose last state was `stopped`, -`startup_failed`, `starting`, or absent get their slot recreated in the -`down` state and wait for explicit user action. `docker restart` is therefore -invisible to a user with running profile gateways: they come back up; -stopped ones stay stopped. - -### s6-overlay constraints - -- **Root/non-root model:** `/init` runs as root to set up the supervision - tree, install signal handlers, and run the stage2 hook that does - `usermod`/`chown`. Each supervised service drops to UID 10000 via - `s6-setuidgid hermes` in its `run` script. The per-service `s6-supervise` - monitor stays root so it can signal its child regardless of UID. Net - effect: hermes and all its subprocesses run as UID 10000 exactly as - before; only the supervision tree itself runs as root. -- v3.2.3.0 has limited non-root support for running `/init` itself as - non-root — some tools (`fix-attrs`, `logutil-service`) assume root. We - don't hit this because `/init` runs as root. -- Scandir hard cap: `services_max` default 1000, configurable to 160,000. -- `/command/with-contenv` sources `/run/s6/container_environment/*` into - service env — convenient for passing `HERMES_HOME` etc. -- s6 signal semantics: service crash triggers `s6-supervise` restart after - 1s; override with a `finish` script. -- Zombie reaping: PID 1 (s6-svscan) reaps all zombies non-blockingly on - SIGCHLD. Any subagent subprocess spawned by the main hermes process is - reaped automatically. - ---- - -## Key Design Decisions - -### D1. s6-overlay replaces tini entirely - -Container ENTRYPOINT is `/init`, PID 1 is s6-svscan. The main hermes -process, the dashboard, and every per-profile gateway run as supervised -services. This is a single breaking change to the container contract. - -### D2. Main hermes is an s6 service with container-exit semantics - -The contract "container exits when `hermes` exits" is preserved via a -service `finish` script that writes to -`/run/s6-linux-init-container-results/exitcode` and calls -`/run/s6/basedir/bin/halt`. All five supported invocations work: - -| `docker run <image> …` | Behavior | -|---|---| -| (no args) | `hermes` with no args, container exits when hermes exits | -| `chat -q "..."` | `hermes chat -q "..."`, container exits with hermes exit code | -| `sleep infinity` | `sleep infinity` directly (long-lived sandbox mode) | -| `bash` | interactive `bash` directly | -| `docker run -it … --tui` | interactive Ink TUI with real TTY — see D9 | - -`docker/main-wrapper.sh` detects whether `$1` is an executable on PATH and -routes either to "run this as a one-shot main service" or "wrap with -hermes". - -### D3. Static services at build time; dynamic (per-profile) services at runtime - -s6 offers two mechanisms: - -- **s6-rc** (declarative, compile-then-swap): used for main hermes and the - dashboard — they're known at image build time. -- **scandir** (drop a directory + `s6-svscanctl -a`): used for per-profile - gateways — profiles are user-created after the image is built. - -Per-profile gateway service dirs live at `/run/service/gateway-<profile>/` -(tmpfs, hermes-writable). s6-svscan picks them up on rescan. - -### D4. ServiceManager protocol with two methods for runtime registration - -Host paths (systemd, launchd, Windows Scheduled Tasks) need only -install/start/stop/restart of pre-declared services. Inside the container, -we additionally need to register services at runtime when a profile is -created. The protocol exposes this directly: - -```python -class ServiceManager(Protocol): - kind: ServiceManagerKind # "systemd" | "launchd" | "windows" | "s6" | "none" - - # Lifecycle of an already-declared service - def start(self, name: str) -> None: ... - def stop(self, name: str) -> None: ... - def restart(self, name: str) -> None: ... - def is_running(self, name: str) -> bool: ... - - # Runtime registration (container-only; hosts raise NotImplementedError) - def supports_runtime_registration(self) -> bool: ... - def register_profile_gateway( - self, profile: str, *, - extra_env: dict[str, str] | None = None, - ) -> None: ... - def unregister_profile_gateway(self, profile: str) -> None: ... - def list_profile_gateways(self) -> list[str]: ... -``` - -Systemd, launchd, and Windows backends raise `NotImplementedError` on the -registration methods. Only the s6 backend implements them. Callers check -`supports_runtime_registration()` before calling. - -The scope is intentionally narrow: it's specifically "register/unregister a -profile gateway," not a general-purpose process-management API. - -### D5. Per-profile gateway service spec is fixed, not user-provided - -Every profile gateway has the same command shape -(`hermes -p <profile> gateway run`, or `hermes gateway run` for the default -profile). The s6 backend generates the `run` script from a fixed template -given the profile name — no arbitrary command list. This keeps the API -surface tight and prevents callers from accidentally registering -non-gateway services. - -Port selection is governed by the profile's `config.yaml` -(`[gateway] port = …`) — the single source of truth. (The original plan -proposed a Python-side SHA-256 port allocator with a 600-port range; it was -retired during PR review because it was dead code through the entire stack.) - -### D6. Add detect_service_manager() alongside supports_systemd_services() - -`supports_systemd_services()` stays as-is (host code paths unchanged). A new -`detect_service_manager() -> Literal["systemd", "launchd", "windows", "s6", "none"]` -composes existing detection functions (`is_macos()`, `is_windows()`, -`supports_systemd_services()`, `is_container()` + `_s6_running()`) and adds -an s6 branch for container detection. Host call sites continue to use the -existing functions; container-only code (the profile hooks) uses the new one. - -`_s6_running()` probes `/proc/1/comm` (world-readable) and -`/run/s6/basedir`. The earlier `/proc/1/exe` probe was root-only readable -and silently failed for the unprivileged hermes user (UID 10000), making -the entire runtime-registration path inert in production — caught in PR -review. - -### D7. Wrap existing systemd/launchd/windows functions, don't rewrite them - -`SystemdServiceManager` / `LaunchdServiceManager` / `WindowsServiceManager` -are thin adapters over the existing `systemd_*` / `launchd_*` module-level -functions in `hermes_cli/gateway.py` and the -`gateway_windows.install/uninstall/start/stop/restart/is_installed` -functions in `hermes_cli/gateway_windows.py`. We get the abstraction -without rewriting ~2,200 LOC of working code. - -### D8. Profile create/delete hooks register/unregister the s6 service - -When `hermes profile create <name>` runs inside the container, the -profile-creation code path calls -`ServiceManager.register_profile_gateway(<name>)` if -`supports_runtime_registration()` is True. When `hermes profile delete -<name>` runs, it calls `unregister_profile_gateway(<name>)`. On host, both -calls are no-ops (registration not supported; existing systemd unit -generation continues to handle install/uninstall). - -Existing per-profile `hermes -p <profile> gateway start/stop/restart` CLI -commands continue to work — in the container they dispatch to -`ServiceManager.start/stop/restart("gateway-<profile>")`, which translates -to `s6-svc -u`/`-d`/`-t` on the service dir. - -`hermes gateway start` (no `-p`) targets a special `gateway-default` slot -that's always registered by the cont-init reconciler. Its run script omits -the `-p` flag and runs against the root `$HERMES_HOME` profile. - -`--all` lifecycle (`hermes gateway stop --all`, `... restart --all`) -iterates `mgr.list_profile_gateways()` through s6 so s6's `want up`/`want -down` flips correctly. Without this, `--all` fell through to `pkill` -followed by s6-supervise auto-restart — net effect: kick instead of stop. - -### D9. Interactive TUI bypasses s6 service-mode and runs as CMD for TTY passthrough - -`docker run -it --rm <image> --tui` needs a real TTY connected to container -stdin/stdout for Ink raw-mode keyboard input, cursor control, and SIGWINCH. -Running the TUI as a normal s6 service fails because s6-supervise -disconnects service stdio from the container TTY (documented: -[s6-overlay#230](https://github.com/just-containers/s6-overlay/issues/230)). - -**The pattern:** s6-overlay's `/init` execs a CMD as the container's "main -program" after the supervision tree is up. The CMD inherits -stdin/stdout/stderr from `/init` — which in `-it` mode is the container -TTY. The stage2 hook detects the TUI case and short-circuits the -main-hermes service so the hermes CMD becomes that main program. - -```sh -# In docker/stage2-hook.sh -_is_tui_invocation() { - for arg in "$@"; do - case "$arg" in --tui|-T) return 0 ;; esac - done - case "${HERMES_TUI:-}" in 1|true|TRUE|yes) return 0 ;; esac - if [ -t 0 ] && [ $# -eq 0 ]; then return 0; fi - return 1 -} -``` - -And in `docker/s6-rc.d/main-hermes/run`: - -```sh -if [ -f /var/run/s6/container_environment/HERMES_TUI_MODE ]; then - exec sleep infinity # s6-overlay will exec CMD as the TTY-connected main -fi -exec s6-setuidgid hermes hermes ${HERMES_ARGS:-} -``` - -In TUI mode main hermes is effectively unsupervised (same as the pre-s6 -behavior with tini — acceptable because the user is interactively -present). Dashboard and profile gateways still get full s6 supervision via -their separate services. - -The integration test `test_tty_passthrough_to_container` uses `tput cols` -and `COLUMNS=123` as the probe. - ---- - -## Risk Register - -| Risk | Likelihood | Impact | Mitigation | -|---|---|---|---| -| Phase 2 breaks a downstream user's Dockerfile that `FROM`s ours | Medium | Medium | Release notes call out ENTRYPOINT change; the test harness (`tests/docker/`) gives high confidence in behavior parity | -| TUI TTY passthrough fails on some Docker versions | Low | High | Harness includes `test_tty_passthrough_to_container` as a hard gate; fallback plan = s6-fdholder ([s6-overlay#230](https://github.com/just-containers/s6-overlay/issues/230) Solution 2) | -| s6-overlay non-root quirks (logutil-service, fix-attrs) bite us | Low | Low | Supervisor runs as root, services drop — sidesteps these issues | -| Podman rootless UID mapping confuses s6 | Medium | Low | Documented as supported, fix reactively; a Podman + Docker environment is stood up for validation | -| Test harness is flaky (docker daemon issues, timing) | Medium | Low | Generous timeouts; skip when docker unavailable; polling helpers replace fixed sleeps in `test_container_restart.py` | -| Profile gateway crash loop masks a real config error | Low | Medium | s6 `finish` script `max_restarts` cap (planned follow-up); operators see crash-looping logs in `$HERMES_HOME/logs/gateways/<profile>/` | -| Dockerfile+entrypoint drift from linter (hadolint/shellcheck) reveals latent bugs | Low | Low | CI lint jobs catch them; fix or document ignore with rationale | -| Stale `gateway.pid` from a dead container collides with an unrelated live PID in the restarted container | Low | Medium | Cont-init reconciliation removes `gateway.pid` and `processes.json` from every profile dir on boot, before any new gateway starts | -| `docker restart` silently loses per-profile gateway registrations (tmpfs scandir wiped) | High (without mitigation) | High | Cont-init reconciliation re-registers from persistent `$HERMES_HOME/profiles/` and auto-starts those last seen `running`; outcome recorded to `$HERMES_HOME/logs/container-boot.log` (size-bounded, rotates to `.1` at 256 KiB) | -| A `running` gateway that's actually broken auto-restarts into a crash loop after every container restart | Low | Medium | s6 `finish` script `max_restarts` cap (planned); follow-up: `hermes doctor` alerts when N consecutive container restarts ended in `startup_failed` | -| `_s6_running()` detection works as root but silently fails for unprivileged hermes user, making runtime-registration path inert | High (without mitigation) | High | **Caught in PR review.** Detection now probes `/proc/1/comm` (world-readable) + `/run/s6/basedir`. Docker integration tests refactored to `docker exec -u hermes` so the realistic runtime user is exercised | -| `s6-svscanctl` from hermes hits EACCES on the root-owned control FIFO | Medium | Medium | `02-reconcile-profiles` chowns `/run/service/.s6-svscan/{control,lock}` to hermes after stage1 creates them | -| Per-service `supervise/control` FIFO is root-owned by s6-supervise, blocking `s6-svc` from hermes | Known | Medium | Surfaced cleanly as `S6CommandError` (with rc + stderr) instead of raw `CalledProcessError`. Permission fix tracked as a follow-up (small SUID helper, polling chown loop in cont-init.d, or replace `s6-svc` with `down`-marker manipulation) | - ---- - -## Decision Log - -| # | Question | Decision | -|---|---|---| -| OQ1 | Gate Phase 2 behind env var? | Ship directly (Hermes is pre-1.0; users can pin the previous image) | -| OQ2 | s6 root model | Root `/init`, drop per-service via `s6-setuidgid hermes` | -| OQ3 | Dashboard opt-in mechanism | Always declared as an s6 service; `03-dashboard-toggle` cont-init script writes a `down` marker when `HERMES_DASHBOARD` is unset so `s6-svstat` reports the slot's real state | -| OQ4 | Podman rootless | Supported, fix reactively | -| OQ5 | Service naming | `gateway-<profile>` (matches pre-existing `hermes-gateway-<profile>.service` systemd convention) | -| OQ6 | — (retired; no subagent gateways in scope) | — | -| OQ7 | Resource limits per profile gateway | Defer (no per-cgroup limits; rely on the container's overall limit) | -| OQ8 | Log persistence | `$HERMES_HOME/logs/gateways/<profile>/`. The log path is sourced from runtime `$HERMES_HOME` via `with-contenv`, NOT Python-substituted at registration time | -| OQ9 | TUI passthrough | Trust the documented [s6-overlay#230](https://github.com/just-containers/s6-overlay/issues/230) Solution 1; harness includes a TTY passthrough hard-gate test | - -**Post-merge additions from PR #30136 review:** - -- **Multi-arch tarballs:** `TARGETARCH` mapped to `x86_64` / `aarch64`; - per-arch tarball fetched via `curl` because `ADD` doesn't honor BuildKit - args. -- **SHA256 verification:** all three tarballs (noarch, symlinks, per-arch) - pinned via build ARGs and verified with `sha256sum -c` against a single - checksum file (avoids hadolint DL4006 piped-shell warning). -- **`gateway-default` slot:** always registered by the reconciler so - `hermes gateway start` (no `-p`) has somewhere to land. -- **Friendly lifecycle errors:** `GatewayNotRegisteredError` and - `S6CommandError` translate `CalledProcessError` into actionable CLI - messages. -- **Atomic publication in the reconciler:** mirrors - `register_profile_gateway`'s tmp+rename pattern. -- **`container-boot.log` rotation:** 256 KiB soft cap, rotated to `.1`. -- **`port` parameter retired:** allocator + kwarg were dead code through - the entire stack; `config.yaml` is the single source of truth. - ---- - -## Verification Checklist - -- [x] Test harness (`tests/docker/`) passes against the s6 image -- [x] hadolint + shellcheck run green in CI -- [x] `docker run -it --rm hermes-agent --tui` starts the Ink TUI with - working keyboard input, cursor control, and resize (SIGWINCH) -- [x] Dashboard crashes are recovered by s6 within ~2s -- [x] `hermes profile create test` inside a container creates - `/run/service/gateway-test/` -- [x] `hermes -p test gateway start` inside a container dispatches through s6 -- [x] `hermes -p test gateway stop` inside a container cleanly stops via s6 -- [x] `hermes profile delete test` inside a container removes - `/run/service/gateway-test/` -- [x] Profile gateway logs persist at - `$HERMES_HOME/logs/gateways/test/current` -- [x] `hermes status` inside the container shows `Manager: s6` -- [x] `hermes gateway start` (no `-p`) inside a container targets - `gateway-default` and runs against the root profile -- [x] `hermes gateway stop --all` / `... restart --all` iterate every - profile gateway under s6 instead of pkill-then-supervise-restart -- [x] `docker restart` survives per-profile gateway registrations via the - cont-init reconciler; running gateways come back up, stopped ones - stay down -- [x] Multi-arch image builds for both `linux/amd64` and `linux/arm64` -- [x] s6-overlay tarballs are SHA256-verified at build time -- [x] No systemd/launchd host-side functions were modified (only wrapped) -- [x] `hermes gateway install/start/stop` on Linux host and macOS host - behave identically to pre-change diff --git a/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md b/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md deleted file mode 100644 index 4946291d4..000000000 --- a/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md +++ /dev/null @@ -1,152 +0,0 @@ -# ACP Zed Pre-Edit Approval Diffs Implementation Plan - -> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. - -**Goal:** Gate file mutations in ACP/Zed behind explicit pre-edit approval with a structured diff, similar to Codex/Kimi edit review behavior. - -**Architecture:** Hermes already renders edit diffs after tools run. This PR adds a pre-mutation permission gate for file mutation tools. Intercept `write_file`, `patch`, and eventually `skill_manage` before they mutate disk; compute proposed old/new content; send ACP `session/request_permission` with `kind="edit"` and diff content; only execute the mutation after approval. Rejections return a clear tool result and leave files unchanged. - -**Tech Stack:** Python, ACP `request_permission`, `FileEditToolCallContent` / `acp.tool_diff_content`, Hermes file tools, pytest with temp files. - ---- - -### Task 1: Confirm current ACP diff/permission schema - -Run: - -```bash -/home/nour/.hermes/hermes-agent/venv/bin/python - <<'PY' -from acp.schema import RequestPermissionRequest, ToolCallUpdate -import acp, inspect -print(RequestPermissionRequest.model_fields) -print(ToolCallUpdate.model_fields) -print(inspect.signature(acp.tool_diff_content)) -PY -``` - -Record actual field names. Do not rely on stale examples. - -### Task 2: Add denied-write test - -**Objective:** A rejected `write_file` must not mutate disk. - -**Files:** -- Create/modify: `tests/acp/test_edit_approval.py` - -Test shape: - -```python -def test_write_file_rejected_by_acp_permission_does_not_mutate(tmp_path): - path = tmp_path / "demo.txt" - path.write_text("old") - - # Install fake ACP edit approval callback returning reject_once. - # Invoke the same interception function that the terminal/tool path will call. - - result = maybe_gate_file_edit( - tool_name="write_file", - args={"path": str(path), "content": "new"}, - approval_requester=fake_reject, - ) - - assert path.read_text() == "old" - assert "rejected" in result.lower() -``` - -The exact function name will be created in Task 4. - -### Task 3: Add approved-write test - -**Objective:** Approved writes proceed and include diff content in permission request. - -Assert: - -- fake requester received tool call `kind == "edit"` -- content includes diff block for `demo.txt` -- after approval, file content is changed - -### Task 4: Implement edit proposal computation - -**Files:** -- Create: `acp_adapter/edit_approval.py` - -Add pure helpers first: - -```python -@dataclass -class EditProposal: - path: str - old_text: str | None - new_text: str - title: str - - -def proposal_for_write_file(args: dict[str, Any]) -> EditProposal: - path = str(args["path"]) - old_text = Path(path).read_text(encoding="utf-8") if Path(path).exists() else None - new_text = str(args.get("content", "")) - return EditProposal(path=path, old_text=old_text, new_text=new_text, title=f"Edit {path}") -``` - -For `patch`, start with replace-mode only. V4A/multi-file patches can be a second task or second PR if too risky. - -### Task 5: Implement ACP permission requester - -**Files:** -- Modify: `acp_adapter/permissions.py` or new `acp_adapter/edit_approval.py` - -Build request with: - -```python -acp.tool_diff_content(path=proposal.path, old_text=proposal.old_text, new_text=proposal.new_text) -``` - -Options: - -- allow once -- reject once -- optionally allow always/reject always only after policy storage exists - -Default deny on exception/cancel/timeout. - -### Task 6: Intercept file mutation tools before execution - -**Objective:** Ensure mutation cannot happen before approval. - -**Files:** -- Likely modify: `model_tools.py` or `acp_adapter/server.py` session-context tool wrapper - -Do not bury this inside post-execution `acp_adapter/events.py`; that is too late. - -Preferred design: - -- set an ACP session contextvar around `agent.run_conversation(...)` -- in the central tool execution path, before dispatching `write_file`/`patch`, call the ACP edit approval gate if contextvar exists -- if rejected, return a normal tool result string like `{"success": false, "error": "Edit rejected by user"}` -- if approved, continue to original tool implementation - -### Task 7: Expand patch coverage - -Add tests for: - -- `patch` replace mode approved/rejected -- creating a new file via `write_file` -- missing old string -> should fail before approval or return normal patch error, but must not mutate -- permission requester exception -> deny and no mutation - -### Task 8: Verification - -Run: - -```bash -scripts/run_tests.sh tests/acp/test_edit_approval.py tests/acp/test_events.py tests/acp/test_tools.py -q -``` - -Then run manual Zed verification: - -1. Ask Hermes ACP to edit a small file. -2. Confirm Zed shows a diff before mutation. -3. Reject and verify file unchanged. -4. Approve and verify file changed. - -**Do not merge** without manual reject-path verification. diff --git a/gateway/config.py b/gateway/config.py index abc40d85c..59fdfa54e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -801,7 +801,7 @@ def load_gateway_config() -> GatewayConfig: existing = {} # Deep-merge extra dicts so gateway.json defaults survive merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})} - if plat_name == Platform.SLACK.value and "enabled" in plat_block: + if "enabled" in plat_block: merged_extra["_enabled_explicit"] = True merged = {**existing, **plat_block} if merged_extra: @@ -1247,14 +1247,23 @@ def _validate_gateway_config(config: "GatewayConfig") -> None: def _apply_env_overrides(config: GatewayConfig) -> None: """Apply environment variable overrides to config.""" + + def _enable_from_env(platform: Platform) -> PlatformConfig: + if platform not in config.platforms: + config.platforms[platform] = PlatformConfig(enabled=True) + return config.platforms[platform] + + platform_config = config.platforms[platform] + enabled_was_explicit = bool(platform_config.extra.pop("_enabled_explicit", False)) + if not platform_config.enabled and not enabled_was_explicit: + platform_config.enabled = True + return platform_config # Telegram telegram_token = os.getenv("TELEGRAM_BOT_TOKEN") if telegram_token: - if Platform.TELEGRAM not in config.platforms: - config.platforms[Platform.TELEGRAM] = PlatformConfig() - config.platforms[Platform.TELEGRAM].enabled = True - config.platforms[Platform.TELEGRAM].token = telegram_token + telegram_config = _enable_from_env(Platform.TELEGRAM) + telegram_config.token = telegram_token # Reply threading mode for Telegram (off/first/all) telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower() @@ -1283,10 +1292,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # Discord discord_token = os.getenv("DISCORD_BOT_TOKEN") if discord_token: - if Platform.DISCORD not in config.platforms: - config.platforms[Platform.DISCORD] = PlatformConfig() - config.platforms[Platform.DISCORD].enabled = True - config.platforms[Platform.DISCORD].token = discord_token + discord_config = _enable_from_env(Platform.DISCORD) + discord_config.token = discord_token discord_home = os.getenv("DISCORD_HOME_CHANNEL") if discord_home and Platform.DISCORD in config.platforms: @@ -1358,10 +1365,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None: signal_url = os.getenv("SIGNAL_HTTP_URL") signal_account = os.getenv("SIGNAL_ACCOUNT") if signal_url and signal_account: - if Platform.SIGNAL not in config.platforms: - config.platforms[Platform.SIGNAL] = PlatformConfig() - config.platforms[Platform.SIGNAL].enabled = True - config.platforms[Platform.SIGNAL].extra.update({ + signal_config = _enable_from_env(Platform.SIGNAL) + signal_config.extra.update({ "http_url": signal_url, "account": signal_account, "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in {"true", "1", "yes"}, @@ -1381,11 +1386,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: mattermost_url = os.getenv("MATTERMOST_URL", "") if not mattermost_url: logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing") - if Platform.MATTERMOST not in config.platforms: - config.platforms[Platform.MATTERMOST] = PlatformConfig() - config.platforms[Platform.MATTERMOST].enabled = True - config.platforms[Platform.MATTERMOST].token = mattermost_token - config.platforms[Platform.MATTERMOST].extra["url"] = mattermost_url + mattermost_config = _enable_from_env(Platform.MATTERMOST) + mattermost_config.token = mattermost_token + mattermost_config.extra["url"] = mattermost_url mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL") if mattermost_home and Platform.MATTERMOST in config.platforms: config.platforms[Platform.MATTERMOST].home_channel = HomeChannel( @@ -1401,23 +1404,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if matrix_token or os.getenv("MATRIX_PASSWORD"): if not matrix_homeserver: logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing") - if Platform.MATRIX not in config.platforms: - config.platforms[Platform.MATRIX] = PlatformConfig() - config.platforms[Platform.MATRIX].enabled = True + matrix_config = _enable_from_env(Platform.MATRIX) if matrix_token: - config.platforms[Platform.MATRIX].token = matrix_token - config.platforms[Platform.MATRIX].extra["homeserver"] = matrix_homeserver + matrix_config.token = matrix_token + matrix_config.extra["homeserver"] = matrix_homeserver matrix_user = os.getenv("MATRIX_USER_ID", "") if matrix_user: - config.platforms[Platform.MATRIX].extra["user_id"] = matrix_user + matrix_config.extra["user_id"] = matrix_user matrix_password = os.getenv("MATRIX_PASSWORD", "") if matrix_password: - config.platforms[Platform.MATRIX].extra["password"] = matrix_password + matrix_config.extra["password"] = matrix_password matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"} - config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee + matrix_config.extra["encryption"] = matrix_e2ee matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "") if matrix_device_id: - config.platforms[Platform.MATRIX].extra["device_id"] = matrix_device_id + matrix_config.extra["device_id"] = matrix_device_id matrix_home = os.getenv("MATRIX_HOME_ROOM") if matrix_home and Platform.MATRIX in config.platforms: config.platforms[Platform.MATRIX].home_channel = HomeChannel( @@ -1956,3 +1957,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None: ) except Exception as e: logger.debug("Plugin platform enable pass failed: %s", e) + + for platform_config in config.platforms.values(): + platform_config.extra.pop("_enabled_explicit", None) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 45eef2a07..975b70157 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -498,19 +498,9 @@ class SignalAdapter(BasePlatformAdapter): if not data_message: return - # Check for group message. - # Modern Signal groups surface on dataMessage.groupV2.id; legacy V1 - # groups still arrive under dataMessage.groupInfo.groupId. signal-cli - # versions differ in which field they expose for V2 groups — some - # forward the underlying libsignal envelope verbatim (groupV2), others - # normalize everything into groupInfo. Read groupV2 first and fall - # back to groupInfo so V2-only groups aren't misrouted as DMs. + # Check for group message group_info = data_message.get("groupInfo") - group_v2 = data_message.get("groupV2") - group_id = ( - (group_v2.get("id") if isinstance(group_v2, dict) else None) - or (group_info.get("groupId") if isinstance(group_info, dict) else None) - ) + group_id = group_info.get("groupId") if group_info else None is_group = bool(group_id) # Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS: @@ -597,7 +587,7 @@ class SignalAdapter(BasePlatformAdapter): # Build session source source = self.build_source( chat_id=chat_id, - chat_name=(group_info.get("groupName") if isinstance(group_info, dict) else None) or sender_name, + chat_name=group_info.get("groupName") if group_info else sender_name, chat_type=chat_type, user_id=sender, user_name=sender_name or sender, diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 5accfdb41..13564f1e6 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -23,6 +23,7 @@ try: from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from slack_sdk.web.async_client import AsyncWebClient import aiohttp + SLACK_AVAILABLE = True except ImportError: SLACK_AVAILABLE = False @@ -32,6 +33,7 @@ except ImportError: import sys from pathlib import Path as _Path + sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) from gateway.config import Platform, PlatformConfig @@ -59,13 +61,15 @@ logger = logging.getLogger(__name__) # (Python 3.7+), so the value set in _handle_slash_command's task is # visible in _process_message_background's child task. _slash_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( - "_slash_user_id", default=None, + "_slash_user_id", + default=None, ) @dataclass class _ThreadContextCache: """Cache entry for fetched thread context.""" + content: str fetched_at: float = field(default_factory=time.monotonic) message_count: int = 0 @@ -86,6 +90,7 @@ def check_slack_requirements() -> bool: from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from slack_sdk.web.async_client import AsyncWebClient import aiohttp + return { "AsyncApp": AsyncApp, "AsyncSocketModeHandler": AsyncSocketModeHandler, @@ -95,6 +100,7 @@ def check_slack_requirements() -> bool: } from tools.lazy_deps import ensure_and_bind + return ensure_and_bind("platform.slack", _import, globals(), prompt=False) @@ -176,7 +182,11 @@ def _extract_text_from_slack_blocks(blocks: list) -> str: code_text = "\n".join(code_lines) if code_text: lang = elem.get("language", "") - _append_line(f"```{lang}\n{code_text}\n```", quote_depth=quote_depth, bullet=bullet) + _append_line( + f"```{lang}\n{code_text}\n```", + quote_depth=quote_depth, + bullet=bullet, + ) else: rendered = _render_inline_elements([elem]) if rendered: @@ -226,7 +236,11 @@ def _serialize_slack_blocks_for_agent(blocks: list, max_chars: int = 6000) -> st def _sanitize(value): if isinstance(value, list): - return [item for item in (_sanitize(v) for v in value) if item not in (None, {}, [], "")] + return [ + item + for item in (_sanitize(v) for v in value) + if item not in (None, {}, [], "") + ] if isinstance(value, dict): sanitized = {} for key, item in value.items(): @@ -312,9 +326,9 @@ class SlackAdapter(BasePlatformAdapter): self._user_name_cache: Dict[str, str] = {} # user_id → display name self._socket_mode_task: Optional[asyncio.Task] = None # Multi-workspace support - self._team_clients: Dict[str, Any] = {} # team_id → WebClient - self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id - self._channel_team: Dict[str, str] = {} # channel_id → team_id + self._team_clients: Dict[str, Any] = {} # team_id → WebClient + self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id + self._channel_team: Dict[str, str] = {} # channel_id → team_id # Dedup cache: prevents duplicate bot responses when Socket Mode # reconnects redeliver events. self._dedup = MessageDeduplicator() @@ -348,8 +362,190 @@ class SlackAdapter(BasePlatformAdapter): # (channel_id, user_id) to avoid cross-user collisions. # Each value: {"response_url": str, "ts": float} self._slash_command_contexts: Dict[Tuple[str, str], Dict[str, Any]] = {} + # Socket Mode resilience: track runtime connection state so we can + # self-heal when Slack silently drops the websocket. + self._app_token: Optional[str] = None + self._proxy_url: Optional[str] = None + self._socket_watchdog_task: Optional[asyncio.Task] = None + self._socket_reconnect_lock = asyncio.Lock() + self._socket_watchdog_interval_s = 15.0 - def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]: + def _start_socket_mode_handler(self) -> None: + """Start the Slack Socket Mode background task.""" + if not self._app or not self._app_token: + raise RuntimeError("Socket Mode requires an initialized app and app token") + + self._handler = AsyncSocketModeHandler( + self._app, self._app_token, proxy=self._proxy_url + ) + _apply_slack_proxy(self._handler.client, self._proxy_url) + + task = asyncio.create_task(self._handler.start_async()) + self._socket_mode_task = task + task.add_done_callback(self._on_socket_mode_task_done) + + async def _stop_socket_mode_handler(self) -> None: + """Stop Socket Mode handler and task.""" + handler = self._handler + task = self._socket_mode_task + self._handler = None + self._socket_mode_task = None + + if handler is not None: + try: + await handler.close_async() + except Exception as e: # pragma: no cover - defensive logging + logger.warning( + "[Slack] Error while closing Socket Mode handler: %s", + e, + exc_info=True, + ) + + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Socket Mode task failed while stopping", exc_info=True + ) + + async def _socket_transport_connected(self) -> Optional[bool]: + """Best-effort check of current Socket Mode transport state.""" + client = getattr(self._handler, "client", None) + if client is None: + return None + + state = getattr(client, "is_connected", None) + if state is None: + return None + + try: + value = state() if callable(state) else state + if asyncio.iscoroutine(value): + value = await value + return bool(value) + except Exception: # pragma: no cover - optional client API + logger.debug( + "[Slack] Could not inspect Socket Mode transport state", exc_info=True + ) + return None + + async def _restart_socket_mode(self, reason: str) -> None: + """Reconnect Socket Mode without rebuilding adapter state.""" + if not self._running: + return + + async with self._socket_reconnect_lock: + if not self._running or not self._app or not self._app_token: + return + + logger.warning("[Slack] Socket Mode unhealthy (%s); reconnecting", reason) + await self._stop_socket_mode_handler() + + try: + self._start_socket_mode_handler() + except Exception as exc: # pragma: no cover - defensive logging + logger.error( + "[Slack] Socket Mode reconnect failed: %s", exc, exc_info=True + ) + + async def _socket_watchdog_loop(self) -> None: + """Monitor Socket Mode and reconnect if the task/transport dies. + + The body is wrapped in a broad except so a transient bug in + ``_restart_socket_mode`` or the transport probe cannot permanently + disable self-healing — the loop logs and keeps polling. + """ + while self._running: + try: + await asyncio.sleep(self._socket_watchdog_interval_s) + if not self._running: + break + + task = self._socket_mode_task + if task is None: + await self._restart_socket_mode("socket task missing") + continue + + if task.done(): + await self._restart_socket_mode("socket task stopped") + continue + + connected = await self._socket_transport_connected() + if connected is False: + await self._restart_socket_mode("transport disconnected") + except asyncio.CancelledError: + raise + except Exception: # pragma: no cover - defensive logging + logger.warning( + "[Slack] Socket Mode watchdog iteration failed; continuing", + exc_info=True, + ) + + def _on_socket_watchdog_done(self, task: asyncio.Task) -> None: + if task is not self._socket_watchdog_task: + return + if task.cancelled() or not self._running: + return + try: + exc = task.exception() + except (asyncio.CancelledError, Exception): # pragma: no cover + exc = None + if exc is not None: + logger.warning( + "[Slack] Socket Mode watchdog exited with error; restarting: %s", + exc, + exc_info=True, + ) + else: + logger.warning("[Slack] Socket Mode watchdog exited; restarting") + self._socket_watchdog_task = None + self._ensure_socket_watchdog() + + def _ensure_socket_watchdog(self) -> None: + if self._socket_watchdog_task is None or self._socket_watchdog_task.done(): + task = asyncio.create_task(self._socket_watchdog_loop()) + self._socket_watchdog_task = task + task.add_done_callback(self._on_socket_watchdog_done) + + def _on_socket_mode_task_done(self, task: asyncio.Task) -> None: + # Ignore stale tasks from intentional reconnect/shutdown. + if task is not self._socket_mode_task: + return + if task.cancelled(): + return + if not self._running: + return + + exc = None + try: + exc = task.exception() + except asyncio.CancelledError: + return + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Could not inspect Socket Mode task exception", exc_info=True + ) + + if exc is not None: + logger.warning( + "[Slack] Socket Mode task exited with error: %s", exc, exc_info=True + ) + else: + logger.warning("[Slack] Socket Mode task exited unexpectedly") + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(self._restart_socket_mode("socket task exited")) + + def _describe_slack_api_error( + self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None + ) -> Optional[str]: """Convert Slack API auth/permission failures into actionable user-facing text.""" if response is None or not hasattr(response, "get"): return None @@ -358,26 +554,46 @@ class SlackAdapter(BasePlatformAdapter): if not error: return None - file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment") + file_label = str( + (file_obj or {}).get("name") + or (file_obj or {}).get("id") + or "this attachment" + ) needed = str(response.get("needed", "") or "").strip() provided = str(response.get("provided", "") or "").strip() reinstall_hint = " Update the Slack app scopes/settings and reinstall the app to the workspace." provided_hint = f" Current bot scopes: {provided}." if provided else "" if error == "missing_scope": - needed_hint = f"Missing scope: {needed}." if needed else "Missing required Slack scope." + needed_hint = ( + f"Missing scope: {needed}." + if needed + else "Missing required Slack scope." + ) return f"Slack attachment access failed for {file_label}. {needed_hint}{provided_hint}{reinstall_hint}" if error in {"not_authed", "invalid_auth", "account_inactive", "token_revoked"}: return f"Slack attachment access failed for {file_label} because the bot token is not authorized ({error}). Refresh the token/reinstall the app." if error in {"file_not_found", "file_deleted"}: return f"Slack attachment {file_label} is no longer available ({error})." - if error in {"access_denied", "file_access_denied", "no_permission", "not_allowed_token_type", "restricted_action"}: + if error in { + "access_denied", + "file_access_denied", + "no_permission", + "not_allowed_token_type", + "restricted_action", + }: return f"Slack attachment access failed for {file_label} because the bot does not have permission ({error}). Check workspace permissions/scopes and reinstall if needed." return None - def _describe_slack_download_failure(self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]: + def _describe_slack_download_failure( + self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None + ) -> Optional[str]: """Translate Slack download exceptions into user-facing attachment diagnostics.""" - file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment") + file_label = str( + (file_obj or {}).get("name") + or (file_obj or {}).get("id") + or "this attachment" + ) response = getattr(exc, "response", None) api_detail = self._describe_slack_api_error(response, file_obj=file_obj) @@ -399,7 +615,10 @@ class SlackAdapter(BasePlatformAdapter): return f"Slack attachment {file_label} returned HTTP 404 and is no longer reachable." message = str(exc) - if "Slack returned HTML instead of media" in message or "non-image data" in message: + if ( + "Slack returned HTML instead of media" in message + or "non-image data" in message + ): return ( f"Slack attachment access failed for {file_label}: Slack returned an HTML/login or non-media response. " "This usually means a scope, auth, or file-permission problem." @@ -415,7 +634,8 @@ class SlackAdapter(BasePlatformAdapter): # as ephemeral if the command handler was slow or dropped. def _pop_slash_context( - self, chat_id: str, + self, + chat_id: str, ) -> Optional[Dict[str, Any]]: """Return and remove the slash-command context for *chat_id*, if fresh. @@ -431,7 +651,8 @@ class SlackAdapter(BasePlatformAdapter): now = time.monotonic() # Clean up stale entries on every lookup — dict is small. stale_keys = [ - k for k, v in self._slash_command_contexts.items() + k + for k, v in self._slash_command_contexts.items() if now - v["ts"] > self._SLASH_CTX_TTL ] for k in stale_keys: @@ -498,7 +719,8 @@ class SlackAdapter(BasePlatformAdapter): ) except Exception as e: logger.warning( - "[Slack] response_url POST failed: %s", e, + "[Slack] response_url POST failed: %s", + e, ) # Non-fatal — the user saw the initial ack already. return SendResult(success=True, message_id=None) @@ -523,13 +745,17 @@ class SlackAdapter(BasePlatformAdapter): proxy_url = _resolve_slack_proxy_url() if proxy_url: - logger.info("[Slack] Using proxy for Slack transport: %s", safe_url_for_log(proxy_url)) + logger.info( + "[Slack] Using proxy for Slack transport: %s", + safe_url_for_log(proxy_url), + ) # Support comma-separated bot tokens for multi-workspace bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()] # Also load tokens from OAuth token file from hermes_constants import get_hermes_home + tokens_file = get_hermes_home() / "slack_tokens.json" if tokens_file.exists(): try: @@ -538,16 +764,44 @@ class SlackAdapter(BasePlatformAdapter): tok = entry.get("token", "") if isinstance(entry, dict) else "" if tok and tok not in bot_tokens: bot_tokens.append(tok) - team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id - logger.info("[Slack] Loaded saved token for workspace %s", team_label) + team_label = ( + entry.get("team_name", team_id) + if isinstance(entry, dict) + else team_id + ) + logger.info( + "[Slack] Loaded saved token for workspace %s", team_label + ) except Exception as e: logger.warning("[Slack] Failed to read %s: %s", tokens_file, e) lock_acquired = False try: - if not self._acquire_platform_lock('slack-app-token', app_token, 'Slack app token'): + if not self._acquire_platform_lock( + "slack-app-token", app_token, "Slack app token" + ): return False lock_acquired = True + self._running = False + + # Tear down any prior reconnect state before flipping ``_running`` + # back on. We must cancel + await the existing watchdog (not just + # check ``task.done()`` later) so an old watchdog can't observe + # ``_running=False``, exit, and then leave us with no monitor when + # ``_ensure_socket_watchdog`` runs before the new task is visible. + watchdog_task = self._socket_watchdog_task + self._socket_watchdog_task = None + if watchdog_task is not None and not watchdog_task.done(): + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Prior watchdog task failed while stopping", + exc_info=True, + ) # Close any previous handler before creating a new one so that # calling connect() a second time (e.g. during a gateway restart or @@ -555,14 +809,18 @@ class SlackAdapter(BasePlatformAdapter): # connection alive. Both the old and new connections would otherwise # receive every Slack event and dispatch it twice, producing double # responses — the same bug that affected DiscordAdapter (#18187). - if self._handler is not None: - try: - await self._handler.close_async() - except Exception: - logger.debug("[%s] Failed to close previous Slack handler", self.name) - finally: - self._handler = None - self._app = None + await self._stop_socket_mode_handler() + self._app = None + self._app_token = app_token + self._proxy_url = proxy_url + + # Reset multi-workspace state before re-populating it so a + # reconnect that drops a workspace (or rotates the primary bot + # token) doesn't carry stale ``_bot_user_id`` / ``_team_clients`` + # / ``_team_bot_user_ids`` entries from the prior session. + self._bot_user_id = None + self._team_clients = {} + self._team_bot_user_ids = {} # First token is the primary — used for AsyncApp / Socket Mode primary_token = bot_tokens[0] @@ -582,13 +840,17 @@ class SlackAdapter(BasePlatformAdapter): self._team_clients[team_id] = client self._team_bot_user_ids[team_id] = bot_user_id - # First token sets the primary bot_user_id (backward compat) + # First token always wins as the primary bot user id; we + # cleared ``_bot_user_id`` above so this picks up the current + # token's identity even on reconnect. if self._bot_user_id is None: self._bot_user_id = bot_user_id logger.info( "[Slack] Authenticated as @%s in workspace %s (team: %s)", - bot_name, team_name, team_id, + bot_name, + team_name, + team_id, ) # Register message event handler @@ -681,12 +943,25 @@ class SlackAdapter(BasePlatformAdapter): ): self._app.action(_action_id)(self._handle_slash_confirm_action) - # Start Socket Mode handler in background - self._handler = AsyncSocketModeHandler(self._app, app_token, proxy=proxy_url) - _apply_slack_proxy(self._handler.client, proxy_url) - self._socket_mode_task = asyncio.create_task(self._handler.start_async()) + # Bring up the handler and watchdog atomically. ``_running`` only + # flips to True after the handler is alive so the watchdog loop + # observes the live task immediately; on any failure here we tear + # down whatever we managed to start, leave ``_running=False``, and + # let the ``finally`` block release the platform lock cleanly. + try: + self._start_socket_mode_handler() + self._running = True + self._ensure_socket_watchdog() + except Exception: + self._running = False + try: + await self._stop_socket_mode_handler() + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Cleanup after failed start raised", exc_info=True + ) + raise - self._running = True logger.info( "[Slack] Socket Mode connected (%d workspace(s))", len(self._team_clients), @@ -720,30 +995,54 @@ class SlackAdapter(BasePlatformAdapter): client = self._get_client(parent_chat_id) if client is None: return None - seed_text = f":thread: Hermes handoff — *{(name or 'session').strip()[:80]}*" + seed_text = ( + f":thread: Hermes handoff — *{(name or 'session').strip()[:80]}*" + ) result = await client.chat_postMessage( channel=parent_chat_id, text=seed_text, ) - ts = result.get("ts") if isinstance(result, dict) else getattr(result, "get", lambda _k, _d=None: None)("ts") + ts = ( + result.get("ts") + if isinstance(result, dict) + else getattr(result, "get", lambda _k, _d=None: None)("ts") + ) if ts: return str(ts) except Exception as exc: logger.warning( "[%s] Handoff thread: seed-post failed for channel %s: %s", - self.name, parent_chat_id, exc, + self.name, + parent_chat_id, + exc, ) return None async def disconnect(self) -> None: """Disconnect from Slack.""" - if self._handler: - try: - await self._handler.close_async() - except Exception as e: # pragma: no cover - defensive logging - logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True) self._running = False + watchdog_task = self._socket_watchdog_task + self._socket_watchdog_task = None + if watchdog_task is not None and not watchdog_task.done(): + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + except Exception: # pragma: no cover - defensive logging + # Watchdog may have lost the cancellation race and exited with + # an unrelated exception. Log and continue so handler cleanup + # and lock release still happen. + logger.debug( + "[Slack] Watchdog task raised during disconnect", exc_info=True + ) + + await self._stop_socket_mode_handler() + self._app = None + self._app_token = None + self._proxy_url = None + self._release_platform_lock() logger.info("[Slack] Disconnected") @@ -775,7 +1074,8 @@ class SlackAdapter(BasePlatformAdapter): slash_ctx = self._pop_slash_context(chat_id) if slash_ctx: return await self._send_slash_ephemeral( - slash_ctx, content, + slash_ctx, + content, ) # Convert standard markdown → Slack mrkdwn @@ -1070,7 +1370,7 @@ class SlackAdapter(BasePlatformAdapter): thread_ts = self._resolve_thread_ts(None, metadata) CHUNK = 10 - chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)] + chunks = [images[i : i + CHUNK] for i in range(0, len(images), CHUNK)] for chunk_idx, chunk in enumerate(chunks): if human_delay > 0 and chunk_idx > 0: @@ -1079,7 +1379,9 @@ class SlackAdapter(BasePlatformAdapter): file_uploads: List[Dict[str, Any]] = [] initial_comment_parts: List[str] = [] try: - async with _httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http_client: + async with _httpx.AsyncClient( + timeout=30.0, follow_redirects=True + ) as http_client: for image_url, alt_text in chunk: if alt_text: initial_comment_parts.append(alt_text) @@ -1087,15 +1389,21 @@ class SlackAdapter(BasePlatformAdapter): if image_url.startswith("file://"): local_path = _unquote(image_url[7:]) if not os.path.exists(local_path): - logger.warning("[Slack] Skipping missing image: %s", local_path) + logger.warning( + "[Slack] Skipping missing image: %s", local_path + ) continue - file_uploads.append({ - "file": local_path, - "filename": os.path.basename(local_path), - }) + file_uploads.append( + { + "file": local_path, + "filename": os.path.basename(local_path), + } + ) else: if not _is_safe_url(image_url): - logger.warning("[Slack] Blocked unsafe image URL in batch") + logger.warning( + "[Slack] Blocked unsafe image URL in batch" + ) continue try: response = await http_client.get(image_url) @@ -1108,24 +1416,31 @@ class SlackAdapter(BasePlatformAdapter): ext = "gif" elif "webp" in ct: ext = "webp" - file_uploads.append({ - "content": response.content, - "filename": f"image_{len(file_uploads)}.{ext}", - }) + file_uploads.append( + { + "content": response.content, + "filename": f"image_{len(file_uploads)}.{ext}", + } + ) except Exception as dl_err: logger.warning( "[Slack] Download failed for %s: %s", - safe_url_for_log(image_url), dl_err, + safe_url_for_log(image_url), + dl_err, ) continue if not file_uploads: continue - initial_comment = "\n".join(initial_comment_parts) if initial_comment_parts else "" + initial_comment = ( + "\n".join(initial_comment_parts) if initial_comment_parts else "" + ) logger.info( "[Slack] Sending %d image(s) in single files_upload_v2 (chunk %d/%d)", - len(file_uploads), chunk_idx + 1, len(chunks), + len(file_uploads), + chunk_idx + 1, + len(chunks), ) result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, @@ -1138,12 +1453,18 @@ class SlackAdapter(BasePlatformAdapter): except Exception as e: logger.warning( "[Slack] Multi-image files_upload_v2 failed (chunk %d/%d), falling back to per-image: %s", - chunk_idx + 1, len(chunks), e, + chunk_idx + 1, + len(chunks), + e, exc_info=True, ) - await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) + await super().send_multiple_images( + chat_id, chunk, metadata, human_delay=human_delay + ) - def _record_uploaded_file_thread(self, chat_id: str, thread_ts: Optional[str]) -> None: + def _record_uploaded_file_thread( + self, chat_id: str, thread_ts: Optional[str] + ) -> None: """Treat successful file uploads as bot participation in a thread.""" if not thread_ts: return @@ -1160,15 +1481,21 @@ class SlackAdapter(BasePlatformAdapter): return status_code == 429 or status_code >= 500 body = " ".join( - str(part) for part in ( + str(part) + for part in ( exc, getattr(exc, "message", ""), getattr(exc, "response", None), - ) if part + ) + if part ).lower() if "rate_limited" in body or "ratelimited" in body or "429" in body: return True - if "connection reset" in body or "service unavailable" in body or "temporarily unavailable" in body: + if ( + "connection reset" in body + or "service unavailable" in body + or "temporarily unavailable" in body + ): return True return self._is_retryable_error(body) @@ -1198,24 +1525,24 @@ class SlackAdapter(BasePlatformAdapter): # 1) Protect fenced code blocks (``` ... ```) text = re.sub( - r'(```(?:[^\n]*\n)?[\s\S]*?```)', + r"(```(?:[^\n]*\n)?[\s\S]*?```)", lambda m: _ph(m.group(0)), text, ) # 2) Protect inline code (`...`) - text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text) + text = re.sub(r"(`[^`]+`)", lambda m: _ph(m.group(0)), text) # 3) Convert markdown links [text](url) → <url|text> def _convert_markdown_link(m): label = m.group(1) url = m.group(2).strip() - if url.startswith('<') and url.endswith('>'): + if url.startswith("<") and url.endswith(">"): url = url[1:-1].strip() - return _ph(f'<{url}|{label}>') + return _ph(f"<{url}|{label}>") text = re.sub( - r'(?<!!)\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', + r"(?<!!)\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)", _convert_markdown_link, text, ) @@ -1223,41 +1550,39 @@ class SlackAdapter(BasePlatformAdapter): # 4) Protect existing Slack entities/manual links so escaping and later # formatting passes don't break them. text = re.sub( - r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)', + r"(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)", lambda m: _ph(m.group(1)), text, ) # 5) Protect blockquote markers before escaping - text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE) + text = re.sub(r"^(>+\s)", lambda m: _ph(m.group(0)), text, flags=re.MULTILINE) # 6) Escape Slack control characters in remaining plain text. # Unescape first so already-escaped input doesn't get double-escaped. - text = text.replace('&', '&').replace('<', '<').replace('>', '>') - text = text.replace('&', '&').replace('<', '<').replace('>', '>') + text = text.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace("&", "&").replace("<", "<").replace(">", ">") # 7) Convert headers (## Title) → *Title* (bold) def _convert_header(m): inner = m.group(1).strip() # Strip redundant bold markers inside a header - inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner) - return _ph(f'*{inner}*') + inner = re.sub(r"\*\*(.+?)\*\*", r"\1", inner) + return _ph(f"*{inner}*") - text = re.sub( - r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE - ) + text = re.sub(r"^#{1,6}\s+(.+)$", _convert_header, text, flags=re.MULTILINE) # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic) text = re.sub( - r'\*\*\*(.+?)\*\*\*', - lambda m: _ph(f'*_{m.group(1)}_*'), + r"\*\*\*(.+?)\*\*\*", + lambda m: _ph(f"*_{m.group(1)}_*"), text, ) # 9) Convert bold: **text** → *text* (Slack bold) text = re.sub( - r'\*\*(.+?)\*\*', - lambda m: _ph(f'*{m.group(1)}*'), + r"\*\*(.+?)\*\*", + lambda m: _ph(f"*{m.group(1)}*"), text, ) @@ -1266,15 +1591,15 @@ class SlackAdapter(BasePlatformAdapter): # emphasized text touches non-whitespace on both sides so literal # delimiters like "a * b * c" are preserved. text = re.sub( - r'(?<!\*)\*(\S(?:[^*\n]*?\S)?)\*(?!\*)', - lambda m: _ph(f'_{m.group(1)}_'), + r"(?<!\*)\*(\S(?:[^*\n]*?\S)?)\*(?!\*)", + lambda m: _ph(f"_{m.group(1)}_"), text, ) # 11) Convert strikethrough: ~~text~~ → ~text~ text = re.sub( - r'~~(.+?)~~', - lambda m: _ph(f'~{m.group(1)}~'), + r"~~(.+?)~~", + lambda m: _ph(f"~{m.group(1)}~"), text, ) @@ -1288,9 +1613,7 @@ class SlackAdapter(BasePlatformAdapter): # ----- Reactions ----- - async def _add_reaction( - self, channel: str, timestamp: str, emoji: str - ) -> bool: + async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: """Add an emoji reaction to a message. Returns True on success.""" if not self._app: return False @@ -1304,9 +1627,7 @@ class SlackAdapter(BasePlatformAdapter): logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e) return False - async def _remove_reaction( - self, channel: str, timestamp: str, emoji: str - ) -> bool: + async def _remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: """Remove an emoji reaction from a message. Returns True on success.""" if not self._app: return False @@ -1334,7 +1655,9 @@ class SlackAdapter(BasePlatformAdapter): if channel_id: await self._add_reaction(channel_id, ts, "eyes") - async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: + async def on_processing_complete( + self, event: MessageEvent, outcome: ProcessingOutcome + ) -> None: """Swap the in-progress reaction for a final success/failure reaction.""" if not self._reactions_enabled(): return @@ -1393,9 +1716,13 @@ class SlackAdapter(BasePlatformAdapter): ) -> SendResult: """Send a local image file to Slack by uploading it.""" try: - return await self._upload_file(chat_id, image_path, caption, reply_to, metadata) + return await self._upload_file( + chat_id, image_path, caption, reply_to, metadata + ) except FileNotFoundError: - return SendResult(success=False, error=f"Image file not found: {image_path}") + return SendResult( + success=False, error=f"Image file not found: {image_path}" + ) except Exception as e: # pragma: no cover - defensive logging logger.error( "[%s] Failed to send local Slack image %s: %s", @@ -1422,9 +1749,12 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") from tools.url_safety import is_safe_url + if not is_safe_url(image_url): logger.warning("[Slack] Blocked unsafe image URL (SSRF protection)") - return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata) + return await super().send_image( + chat_id, image_url, caption, reply_to, metadata=metadata + ) try: import httpx @@ -1484,9 +1814,13 @@ class SlackAdapter(BasePlatformAdapter): ) -> SendResult: """Send an audio file to Slack.""" try: - return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata) + return await self._upload_file( + chat_id, audio_path, caption, reply_to, metadata + ) except FileNotFoundError: - return SendResult(success=False, error=f"Audio file not found: {audio_path}") + return SendResult( + success=False, error=f"Audio file not found: {audio_path}" + ) except Exception as e: # pragma: no cover - defensive logging logger.error( "[Slack] Failed to send audio file %s: %s", @@ -1509,7 +1843,9 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") if not os.path.exists(video_path): - return SendResult(success=False, error=f"Video file not found: {video_path}") + return SendResult( + success=False, error=f"Video file not found: {video_path}" + ) try: thread_ts = self._resolve_thread_ts(reply_to, metadata) @@ -1635,7 +1971,9 @@ class SlackAdapter(BasePlatformAdapter): # ----- Internal handlers ----- - def _assistant_thread_key(self, channel_id: str, thread_ts: str) -> Optional[Tuple[str, str]]: + def _assistant_thread_key( + self, channel_id: str, thread_ts: str + ) -> Optional[Tuple[str, str]]: """Return a stable cache key for Slack assistant thread metadata.""" if not channel_id or not thread_ts: return None @@ -1809,11 +2147,16 @@ class SlackAdapter(BasePlatformAdapter): if original_text.startswith("!"): try: from hermes_cli.commands import is_gateway_known_command + first_token = original_text[1:].split(maxsplit=1)[0] # Strip "@suffix" the same way get_command() does, so # forms like ``!stop@hermes`` still resolve. cmd_name = first_token.split("@", 1)[0].lower() - if cmd_name and "/" not in cmd_name and is_gateway_known_command(cmd_name): + if ( + cmd_name + and "/" not in cmd_name + and is_gateway_known_command(cmd_name) + ): original_text = "/" + original_text[1:] except Exception: # pragma: no cover - defensive pass @@ -1966,7 +2309,9 @@ class SlackAdapter(BasePlatformAdapter): # Check allowed channels — if set, only respond in these channels (whitelist) allowed_channels = self._slack_allowed_channels() if allowed_channels and channel_id not in allowed_channels: - logger.debug("[Slack] Ignoring message in non-allowed channel: %s", channel_id) + logger.debug( + "[Slack] Ignoring message in non-allowed channel: %s", channel_id + ) return if channel_id in self._slack_free_response_channels(): @@ -1983,15 +2328,16 @@ class SlackAdapter(BasePlatformAdapter): event_thread_ts is not None and event_thread_ts in self._mentioned_threads ) - has_session = ( - is_thread_reply - and self._has_active_session_for_thread( - channel_id=channel_id, - thread_ts=event_thread_ts, - user_id=user_id, - ) + has_session = is_thread_reply and self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, ) - if not reply_to_bot_thread and not in_mentioned_thread and not has_session: + if ( + not reply_to_bot_thread + and not in_mentioned_thread + and not has_session + ): return if is_mentioned: @@ -2004,7 +2350,9 @@ class SlackAdapter(BasePlatformAdapter): if event_thread_ts and not self._slack_strict_mention(): self._mentioned_threads.add(event_thread_ts) if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX: - to_remove = list(self._mentioned_threads)[:self._MENTIONED_THREADS_MAX // 2] + to_remove = list(self._mentioned_threads)[ + : self._MENTIONED_THREADS_MAX // 2 + ] for t in to_remove: self._mentioned_threads.discard(t) @@ -2045,7 +2393,9 @@ class SlackAdapter(BasePlatformAdapter): if not file_id: continue try: - info_resp = await self._get_client(channel_id).files_info(file=file_id) + info_resp = await self._get_client(channel_id).files_info( + file=file_id + ) if info_resp.get("ok"): f = info_resp["file"] else: @@ -2056,7 +2406,8 @@ class SlackAdapter(BasePlatformAdapter): else: logger.warning( "[Slack] files.info failed for %s: %s", - file_id, info_resp.get("error"), + file_id, + info_resp.get("error"), ) continue except Exception as e: @@ -2066,7 +2417,12 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] files.info error for %s: %s", file_id, e, exc_info=True) + logger.warning( + "[Slack] files.info error for %s: %s", + file_id, + e, + exc_info=True, + ) continue mimetype = f.get("mimetype", "unknown") @@ -2086,13 +2442,20 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True) + logger.warning( + "[Slack] Failed to cache image from %s: %s", + url, + e, + exc_info=True, + ) elif mimetype.startswith("audio/") and url: try: ext = "." + mimetype.split("/")[-1].split(";")[0] if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}: ext = ".ogg" - cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id) + cached = await self._download_slack_file( + url, ext, audio=True, team_id=team_id + ) media_urls.append(cached) media_types.append(mimetype) except Exception as e: # pragma: no cover - defensive logging @@ -2101,7 +2464,12 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True) + logger.warning( + "[Slack] Failed to cache audio from %s: %s", + url, + e, + exc_info=True, + ) elif url: # Try to handle as a document attachment try: @@ -2113,7 +2481,9 @@ class SlackAdapter(BasePlatformAdapter): # Fallback: reverse-lookup from MIME type if not ext and mimetype: - mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + mime_to_ext = { + v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items() + } ext = mime_to_ext.get(mimetype, "") if ext not in SUPPORTED_DOCUMENT_TYPES: @@ -2123,11 +2493,15 @@ class SlackAdapter(BasePlatformAdapter): file_size = f.get("size", 0) MAX_DOC_BYTES = 20 * 1024 * 1024 if not file_size or file_size > MAX_DOC_BYTES: - logger.warning("[Slack] Document too large or unknown size: %s", file_size) + logger.warning( + "[Slack] Document too large or unknown size: %s", file_size + ) continue # Download and cache - raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id) + raw_bytes = await self._download_slack_file_bytes( + url, team_id=team_id + ) cached_path = cache_document_from_bytes( raw_bytes, original_filename or f"document{ext}" ) @@ -2140,14 +2514,26 @@ class SlackAdapter(BasePlatformAdapter): # snippets like JSON/YAML/configs are actually visible to the agent. MAX_TEXT_INJECT_BYTES = 100 * 1024 TEXT_INJECT_EXTENSIONS = { - ".md", ".txt", ".csv", ".log", ".json", ".xml", - ".yaml", ".yml", ".toml", ".ini", ".cfg", + ".md", + ".txt", + ".csv", + ".log", + ".json", + ".xml", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", } - if ext in TEXT_INJECT_EXTENSIONS and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + if ( + ext in TEXT_INJECT_EXTENSIONS + and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES + ): try: text_content = raw_bytes.decode("utf-8") display_name = original_filename or f"document{ext}" - display_name = re.sub(r'[^\w.\- ]', '_', display_name) + display_name = re.sub(r"[^\w.\- ]", "_", display_name) injection = f"[Content of {display_name}]:\n{text_content}" if text: text = f"{injection}\n\n{text}" @@ -2162,10 +2548,17 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True) + logger.warning( + "[Slack] Failed to cache document from %s: %s", + url, + e, + exc_info=True, + ) if attachment_notices: - notice_block = "[Slack attachment notice]\n" + "\n".join(f"- {n}" for n in attachment_notices) + notice_block = "[Slack attachment notice]\n" + "\n".join( + f"- {n}" for n in attachment_notices + ) text = f"{notice_block}\n\n{text}" if text else notice_block if msg_type != MessageType.COMMAND and media_types: @@ -2190,12 +2583,20 @@ class SlackAdapter(BasePlatformAdapter): ) # Per-channel ephemeral prompt - from gateway.platforms.base import resolve_channel_prompt, resolve_channel_skills + from gateway.platforms.base import ( + resolve_channel_prompt, + resolve_channel_skills, + ) + _channel_prompt = resolve_channel_prompt( - self.config.extra, channel_id, None, + self.config.extra, + channel_id, + None, ) _auto_skill = resolve_channel_skills( - self.config.extra, channel_id, None, + self.config.extra, + channel_id, + None, ) # Extract reply context if this message is a thread reply. @@ -2206,11 +2607,14 @@ class SlackAdapter(BasePlatformAdapter): reply_to_text = None if thread_ts and thread_ts != ts: try: - reply_to_text = await self._fetch_thread_parent_text( - channel_id=channel_id, - thread_ts=thread_ts, - team_id=team_id, - ) or None + reply_to_text = ( + await self._fetch_thread_parent_text( + channel_id=channel_id, + thread_ts=thread_ts, + team_id=team_id, + ) + or None + ) except Exception: # pragma: no cover - defensive reply_to_text = None @@ -2240,7 +2644,10 @@ class SlackAdapter(BasePlatformAdapter): # ----- Approval button support (Block Kit) ----- async def send_exec_approval( - self, chat_id: str, command: str, session_key: str, + self, + chat_id: str, + command: str, + session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: @@ -2320,8 +2727,13 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error=str(e)) async def send_slash_confirm( - self, chat_id: str, title: str, message: str, session_key: str, - confirm_id: str, metadata: Optional[Dict[str, Any]] = None, + self, + chat_id: str, + title: str, + message: str, + session_key: str, + confirm_id: str, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a Block Kit three-option slash-command confirmation prompt.""" if not self._app: @@ -2378,7 +2790,9 @@ class SlackAdapter(BasePlatformAdapter): kwargs["thread_ts"] = thread_ts result = await self._get_client(chat_id).chat_postMessage(**kwargs) - return SendResult(success=True, message_id=result.get("ts", ""), raw_response=result) + return SendResult( + success=True, message_id=result.get("ts", ""), raw_response=result + ) except Exception as e: logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True) return SendResult(success=False, error=str(e)) @@ -2402,7 +2816,8 @@ class SlackAdapter(BasePlatformAdapter): if "*" not in allowed_ids and user_id not in allowed_ids: logger.warning( "[Slack] Unauthorized slash-confirm click by %s (%s) — ignoring", - user_name, user_id, + user_name, + user_id, ) return @@ -2463,7 +2878,10 @@ class SlackAdapter(BasePlatformAdapter): # Resolve via the module-level primitive and post any follow-up. try: from tools import slash_confirm as _slash_confirm_mod - result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice) + + result_text = await _slash_confirm_mod.resolve( + session_key, confirm_id, choice + ) if result_text: post_kwargs: Dict[str, Any] = { "channel": channel_id, @@ -2476,10 +2894,16 @@ class SlackAdapter(BasePlatformAdapter): await self._get_client(channel_id).chat_postMessage(**post_kwargs) logger.info( "Slack button resolved slash-confirm for session %s (choice=%s, user=%s)", - session_key, choice, user_name, + session_key, + choice, + user_name, ) except Exception as exc: - logger.error("Failed to resolve slash-confirm from Slack button: %s", exc, exc_info=True) + logger.error( + "Failed to resolve slash-confirm from Slack button: %s", + exc, + exc_info=True, + ) async def _handle_approval_action(self, ack, body, action) -> None: """Handle an approval button click from Block Kit.""" @@ -2502,7 +2926,8 @@ class SlackAdapter(BasePlatformAdapter): if "*" not in allowed_ids and user_id not in allowed_ids: logger.warning( "[Slack] Unauthorized approval click by %s (%s) — ignoring", - user_name, user_id, + user_name, + user_id, ) return @@ -2564,21 +2989,31 @@ class SlackAdapter(BasePlatformAdapter): # Resolve the approval — this unblocks the agent thread try: from tools.approval import resolve_gateway_approval + count = resolve_gateway_approval(session_key, choice) logger.info( "Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)", - count, session_key, choice, user_name, + count, + session_key, + choice, + user_name, ) except Exception as exc: - logger.error("Failed to resolve gateway approval from Slack button: %s", exc) + logger.error( + "Failed to resolve gateway approval from Slack button: %s", exc + ) # (approval state already consumed by atomic pop above) # ----- Thread context fetching ----- async def _fetch_thread_context( - self, channel_id: str, thread_ts: str, current_ts: str, - team_id: str = "", limit: int = 30, + self, + channel_id: str, + thread_ts: str, + current_ts: str, + team_id: str = "", + limit: int = 30, ) -> str: """Fetch recent thread messages to provide context when the bot is mentioned mid-thread for the first time. @@ -2624,10 +3059,11 @@ class SlackAdapter(BasePlatformAdapter): or "rate_limited" in err_str ) if is_rate_limit and attempt < 2: - retry_after = 1.0 * (2 ** attempt) # 1s, 2s + retry_after = 1.0 * (2**attempt) # 1s, 2s logger.warning( "[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)", - retry_after, attempt + 1, + retry_after, + attempt + 1, ) await asyncio.sleep(retry_after) continue @@ -2657,9 +3093,7 @@ class SlackAdapter(BasePlatformAdapter): # Identify "our own" bot for this workspace (multi-workspace safe). msg_team = msg.get("team") or team_id self_bot_uid = ( - self._team_bot_user_ids.get(msg_team) - if msg_team - else None + self._team_bot_user_ids.get(msg_team) if msg_team else None ) or self._bot_user_id # Exclude only our own prior bot replies (circular context). @@ -2714,7 +3148,10 @@ class SlackAdapter(BasePlatformAdapter): return "" async def _fetch_thread_parent_text( - self, channel_id: str, thread_ts: str, team_id: str = "", + self, + channel_id: str, + thread_ts: str, + team_id: str = "", ) -> str: """Return the raw text of the thread parent message (for reply_to_text). @@ -2783,6 +3220,7 @@ class SlackAdapter(BasePlatformAdapter): # Empty slash_name falls into this branch for backward compat # with any caller that didn't populate command["command"]. from hermes_cli.commands import slack_subcommand_map + subcommand_map = slack_subcommand_map() subcommand_map["compact"] = "/compress" # Guard against whitespace-only text where ``text`` is truthy but @@ -2790,8 +3228,12 @@ class SlackAdapter(BasePlatformAdapter): parts = text.split() if text else [] first_word = parts[0] if parts else "" if first_word in subcommand_map: - rest = text[len(first_word):].strip() - text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] + rest = text[len(first_word) :].strip() + text = ( + f"{subcommand_map[first_word]} {rest}".strip() + if rest + else subcommand_map[first_word] + ) elif text: pass # Treat as a regular question else: @@ -2814,7 +3256,9 @@ class SlackAdapter(BasePlatformAdapter): event = MessageEvent( text=text, - message_type=MessageType.COMMAND if text.startswith("/") else MessageType.TEXT, + message_type=( + MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + ), source=source, raw_message=command, ) @@ -2873,8 +3317,16 @@ class SlackAdapter(BasePlatformAdapter): # Read session isolation settings from the store's config store_cfg = getattr(session_store, "config", None) - gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True - tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False + gspu = ( + getattr(store_cfg, "group_sessions_per_user", True) + if store_cfg + else True + ) + tspu = ( + getattr(store_cfg, "thread_sessions_per_user", False) + if store_cfg + else False + ) session_key = build_session_key( source, @@ -2887,11 +3339,17 @@ class SlackAdapter(BasePlatformAdapter): except Exception: return False - async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: + async def _download_slack_file( + self, url: str, ext: str, audio: bool = False, team_id: str = "" + ) -> str: """Download a Slack file using the bot token for auth, with retry.""" import httpx - bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token + bot_token = ( + self._team_clients[team_id].token + if team_id and team_id in self._team_clients + else self.config.token + ) async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: for attempt in range(3): @@ -2916,16 +3374,25 @@ class SlackAdapter(BasePlatformAdapter): if audio: from gateway.platforms.base import cache_audio_from_bytes + return cache_audio_from_bytes(response.content, ext) else: from gateway.platforms.base import cache_image_from_bytes + return cache_image_from_bytes(response.content, ext) except (httpx.TimeoutException, httpx.HTTPStatusError) as exc: - if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + if ( + isinstance(exc, httpx.HTTPStatusError) + and exc.response.status_code < 429 + ): raise if attempt < 2: - logger.debug("Slack file download retry %d/2 for %s: %s", - attempt + 1, url[:80], exc) + logger.debug( + "Slack file download retry %d/2 for %s: %s", + attempt + 1, + url[:80], + exc, + ) await asyncio.sleep(1.5 * (attempt + 1)) continue raise @@ -2934,7 +3401,11 @@ class SlackAdapter(BasePlatformAdapter): """Download a Slack file and return raw bytes, with retry.""" import httpx - bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token + bot_token = ( + self._team_clients[team_id].token + if team_id and team_id in self._team_clients + else self.config.token + ) async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: for attempt in range(3): @@ -2952,14 +3423,25 @@ class SlackAdapter(BasePlatformAdapter): "check bot token scopes and file permissions" ) return response.content - except (httpx.TimeoutException, httpx.HTTPStatusError, ValueError) as exc: - if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + except ( + httpx.TimeoutException, + httpx.HTTPStatusError, + ValueError, + ) as exc: + if ( + isinstance(exc, httpx.HTTPStatusError) + and exc.response.status_code < 429 + ): raise if isinstance(exc, ValueError): raise if attempt < 2: - logger.debug("Slack file download retry %d/2 for %s: %s", - attempt + 1, url[:80], exc) + logger.debug( + "Slack file download retry %d/2 for %s: %s", + attempt + 1, + url[:80], + exc, + ) await asyncio.sleep(1.5 * (attempt + 1)) continue raise @@ -2978,7 +3460,12 @@ class SlackAdapter(BasePlatformAdapter): if isinstance(configured, str): return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} + return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in { + "false", + "0", + "no", + "off", + } def _slack_strict_mention(self) -> bool: """When true, channel threads require an explicit @-mention on every @@ -2990,7 +3477,12 @@ class SlackAdapter(BasePlatformAdapter): if isinstance(configured, str): return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("SLACK_STRICT_MENTION", "false").lower() in {"true", "1", "yes", "on"} + return os.getenv("SLACK_STRICT_MENTION", "false").lower() in { + "true", + "1", + "yes", + "on", + } def _slack_free_response_channels(self) -> set: """Return channel IDs where no @mention is required.""" diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index c5d0add7d..8e31fee1f 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -16,7 +16,7 @@ import tempfile import html as _html import re from datetime import datetime, timezone -from typing import Dict, List, Optional, Any +from typing import Dict, List, Optional, Set, Any logger = logging.getLogger(__name__) diff --git a/gateway/run.py b/gateway/run.py index dbe3742ca..933e88af3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10023,24 +10023,6 @@ class GatewayRunner: t("gateway.status.platforms", platforms=', '.join(connected_platforms)), ]) - # Session recap — what was this session ABOUT? Pure local compute, - # no LLM call, no prompt-cache impact. Useful when juggling multiple - # gateway sessions and you want a one-glance reminder of where this - # one left off. Inspired by Claude Code 2.1.114's /recap. - try: - from hermes_cli.session_recap import build_recap - history = self.session_store.load_transcript(session_entry.session_id) - recap = build_recap( - history, - session_title=title, - session_id=session_entry.session_id, - platform=source.platform.value if source else None, - ) - if recap: - lines.extend(["", recap]) - except Exception as exc: # pragma: no cover — defensive - logger.debug("build_recap failed in /status: %s", exc) - return "\n".join(lines) async def _handle_agents_command(self, event: MessageEvent) -> str: @@ -19040,33 +19022,6 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = from hermes_logging import setup_logging setup_logging(hermes_home=_hermes_home, mode="gateway") - # Periodic process memory usage logging (gateway only) — emits a - # grep-friendly "[MEMORY] rss=...MB ..." line every N minutes so - # slow leaks in the long-lived gateway process show up as a time - # series in agent.log / gateway.log. Ported from cline/cline#10343. - # Controlled by the logging.memory_monitor section in config.yaml. - try: - from gateway import memory_monitor as _memory_monitor - - _mm_cfg = {} - try: - # config is loaded a few lines up; re-read the logging section - # here so we pick up user overrides without coupling to local - # variable names inside the start_gateway body. - from hermes_cli.config import load_config as _load_cli_config - - _mm_cfg = (_load_cli_config() or {}).get("logging", {}).get("memory_monitor", {}) or {} - except Exception: - _mm_cfg = {} - if _mm_cfg.get("enabled", True): - try: - _mm_interval = float(_mm_cfg.get("interval_seconds", 300)) - except (TypeError, ValueError): - _mm_interval = 300.0 - _memory_monitor.start_memory_monitoring(interval_seconds=_mm_interval) - except Exception as _mm_exc: - logger.debug("Failed to start memory monitor: %s", _mm_exc) - # Optional stderr handler — level driven by -v/-q flags on the CLI. # verbosity=None (-q/--quiet): no stderr output # verbosity=0 (default): WARNING and above @@ -19323,16 +19278,6 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = except Exception: pass - # Stop the periodic memory monitor (if it was started above). - # This also emits one final "[MEMORY] shutdown rss=..." line so the - # last RSS reading before gateway exit is always in the log. - try: - from gateway import memory_monitor as _memory_monitor - - _memory_monitor.stop_memory_monitoring(timeout=2.0) - except Exception: - pass - if runner.exit_code is not None: raise SystemExit(runner.exit_code) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bb004d944..3105ff566 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -212,7 +212,8 @@ _EXTRA_ENV_KEYS = frozenset({ "MATRIX_RECOVERY_KEY", # Langfuse observability plugin — optional tuning keys + standard SDK vars. # Activation is via plugins.enabled (opt-in through `hermes plugins enable - # observability/langfuse`); credentials gate the plugin at runtime. + # observability/langfuse` or `hermes tools → Langfuse`); credentials gate + # the plugin at runtime. "HERMES_LANGFUSE_ENV", "HERMES_LANGFUSE_RELEASE", "HERMES_LANGFUSE_SAMPLE_RATE", @@ -1201,7 +1202,7 @@ DEFAULT_CONFIG = { "display": { "compact": False, - "personality": "kawaii", + "personality": "", "resume_display": "full", # Recap tuning for /resume and startup resume. The defaults match the # historical hardcoded values; expose them as config so power users can @@ -1407,7 +1408,7 @@ DEFAULT_CONFIG = { "stt": { "enabled": True, - "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) + "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) "local": { "model": "base", # tiny, base, small, medium, large-v3 "language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force @@ -1418,6 +1419,12 @@ DEFAULT_CONFIG = { "mistral": { "model": "voxtral-mini-latest", # voxtral-mini-latest, voxtral-mini-2602 }, + "elevenlabs": { + "model_id": "scribe_v2", # scribe_v2, scribe_v1 + "language_code": "", # auto-detect by default; set to "eng", "spa", "fra", etc. to force + "tag_audio_events": False, + "diarize": False, + }, }, "voice": { @@ -1881,15 +1888,6 @@ DEFAULT_CONFIG = { "level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING "max_size_mb": 5, # Max size per log file before rotation "backup_count": 3, # Number of rotated backup files to keep - # Periodic process memory usage logging (gateway only). Emits a - # grep-friendly "[MEMORY] rss=...MB ..." line at the configured - # interval so slow leaks in the long-lived gateway are visible - # in agent.log / gateway.log as a time series. Ported from - # cline/cline#10343. - "memory_monitor": { - "enabled": True, # Flip to false to silence the periodic line - "interval_seconds": 300, # Default: every 5 minutes - }, }, # Remotely-hosted model catalog manifest. When enabled, the CLI fetches @@ -2736,9 +2734,10 @@ OPTIONAL_ENV_VARS = { "category": "tool", }, "ELEVENLABS_API_KEY": { - "description": "ElevenLabs API key for premium text-to-speech voices", + "description": "ElevenLabs API key for premium text-to-speech voices and Scribe transcription", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/", + "tools": ["elevenlabs_tts", "voice_transcription"], "password": True, "category": "tool", }, @@ -5459,7 +5458,7 @@ def show_config(): print() print(color("◆ Display", Colors.CYAN, Colors.BOLD)) display = config.get('display', {}) - print(f" Personality: {display.get('personality', 'kawaii')}") + print(f" Personality: {display.get('personality') or 'none'}") print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}") print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}") ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {} diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index d600c62c0..ec3c433ce 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -31,11 +31,18 @@ from hermes_cli.config import ( read_raw_config, save_env_value, ) + # display_hermes_home is imported lazily at call sites to avoid ImportError # when hermes_constants is cached from a pre-update version during `hermes update`. from hermes_cli.setup import ( - print_header, print_info, print_success, print_warning, print_error, - prompt, prompt_choice, prompt_yes_no, + print_header, + print_info, + print_success, + print_warning, + print_error, + prompt, + prompt_choice, + prompt_yes_no, ) from hermes_cli.colors import Colors, color @@ -69,6 +76,7 @@ class ProfileGatewayProcess: path: Path pid: int + def _get_service_pids() -> set: """Return PIDs currently managed by systemd or launchd gateway services. @@ -84,9 +92,17 @@ def _get_service_pids() -> set: for scope_args in [["systemctl", "--user"], ["systemctl"]]: try: result = subprocess.run( - scope_args + ["list-units", "hermes-gateway*", - "--plain", "--no-legend", "--no-pager"], - capture_output=True, text=True, timeout=5, + scope_args + + [ + "list-units", + "hermes-gateway*", + "--plain", + "--no-legend", + "--no-pager", + ], + capture_output=True, + text=True, + timeout=5, ) for line in result.stdout.strip().splitlines(): parts = line.split() @@ -95,9 +111,10 @@ def _get_service_pids() -> set: svc = parts[0] try: show = subprocess.run( - scope_args + ["show", svc, - "--property=MainPID", "--value"], - capture_output=True, text=True, timeout=5, + scope_args + ["show", svc, "--property=MainPID", "--value"], + capture_output=True, + text=True, + timeout=5, ) pid = int(show.stdout.strip()) if pid > 0: @@ -113,7 +130,9 @@ def _get_service_pids() -> set: label = get_launchd_label() result = subprocess.run( ["launchctl", "list", label], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if result.returncode == 0: # Output: "PID\tStatus\tLabel" header, then one data line @@ -145,6 +164,7 @@ def _get_parent_pid(pid: int) -> int | None: return None try: import psutil # type: ignore + return psutil.Process(pid).ppid() or None except ImportError: pass @@ -277,7 +297,9 @@ def _get_ancestor_pids() -> set[int]: return ancestors -def _append_unique_pid(pids: list[int], pid: int | None, exclude_pids: set[int]) -> None: +def _append_unique_pid( + pids: list[int], pid: int | None, exclude_pids: set[int] +) -> None: if pid is None or pid <= 0: return if pid == os.getpid() or pid in exclude_pids or pid in pids: @@ -305,18 +327,30 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li "hermes_cli/main.py --profile", "hermes_cli/main.py -p", "hermes gateway", + # Windows: only match invocations that actually carry the ``gateway`` + # subcommand or the gateway-dedicated console-script shim. Bare + # ``hermes.exe --profile`` / ``hermes.exe -p`` would also match + # ``hermes.exe --profile foo dashboard`` and other CLI subcommands, + # producing false-positive gateway PIDs (Copilot review). + "hermes.exe gateway", + "hermes-gateway.exe", "gateway/run.py", ] current_home = str(get_hermes_home().resolve()) + current_home_lc = current_home.lower() current_profile_arg = _profile_arg(current_home) - current_profile_name = current_profile_arg.split()[-1] if current_profile_arg else "" + current_profile_name = ( + current_profile_arg.split()[-1] if current_profile_arg else "" + ) + current_profile_name_lc = current_profile_name.lower() def _matches_current_profile(command: str) -> bool: + command_lc = command.lower() if current_profile_name: return ( - f"--profile {current_profile_name}" in command - or f"-p {current_profile_name}" in command - or f"HERMES_HOME={current_home}" in command + f"--profile {current_profile_name_lc}" in command_lc + or f"-p {current_profile_name_lc}" in command_lc + or f"hermes_home={current_home_lc}" in command_lc ) # Default-profile case: no profile flag in argv. Accept as long as @@ -324,9 +358,12 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li # may be passed via env (not visible in wmic/CIM command line) so # its absence is NOT disqualifying — only a non-matching explicit # HERMES_HOME= in argv is. - if "--profile " in command or " -p " in command: + if "--profile " in command_lc or " -p " in command_lc: return False - if "HERMES_HOME=" in command and f"HERMES_HOME={current_home}" not in command: + if ( + "hermes_home=" in command_lc + and f"hermes_home={current_home_lc}" not in command_lc + ): return False return True @@ -343,7 +380,13 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li if wmic_path is not None: try: result = subprocess.run( - [wmic_path, "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], + [ + wmic_path, + "process", + "get", + "ProcessId,CommandLine", + "/FORMAT:LIST", + ], capture_output=True, text=True, encoding="utf-8", @@ -384,10 +427,11 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li for line in result.stdout.split("\n"): line = line.strip() if line.startswith("CommandLine="): - current_cmd = line[len("CommandLine="):] + current_cmd = line[len("CommandLine=") :] elif line.startswith("ProcessId="): - pid_str = line[len("ProcessId="):] - if any(p in current_cmd for p in patterns) and ( + pid_str = line[len("ProcessId=") :] + current_cmd_lc = current_cmd.lower() + if any(p in current_cmd_lc for p in patterns) and ( all_profiles or _matches_current_profile(current_cmd) ): try: @@ -409,9 +453,14 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li if pid == my_pid or pid in exclude_pids: continue try: - cmdline = open(f"/proc/{pid}/cmdline", "rb").read().decode("utf-8", errors="replace") + cmdline = ( + open(f"/proc/{pid}/cmdline", "rb") + .read() + .decode("utf-8", errors="replace") + ) cmdline = cmdline.replace("\x00", " ") - if any(p in cmdline for p in patterns) and ( + cmdline_lc = cmdline.lower() + if any(p in cmdline_lc for p in patterns) and ( all_profiles or _matches_current_profile(cmdline) ): _append_unique_pid(pids, pid, exclude_pids) @@ -454,7 +503,8 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li if pid is None: continue - if any(pattern in command for pattern in patterns) and ( + command_lc = command.lower() + if any(pattern in command_lc for pattern in patterns) and ( all_profiles or _matches_current_profile(command) ): _append_unique_pid(pids, pid, exclude_pids) @@ -508,7 +558,9 @@ def _filter_venv_launcher_stubs(pids: list[int]) -> list[int]: return [p for p in pids if p not in drop] -def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = False) -> list: +def find_gateway_pids( + exclude_pids: set | None = None, all_profiles: bool = False +) -> list: """Find PIDs of running gateway processes. Args: @@ -557,7 +609,9 @@ def find_profile_gateway_processes( if pid is None or pid <= 0 or pid in _exclude or pid in seen: continue seen.add(pid) - processes.append(ProfileGatewayProcess(profile=profile.name, path=profile.path, pid=pid)) + processes.append( + ProfileGatewayProcess(profile=profile.name, path=profile.path, pid=pid) + ) return processes @@ -635,7 +689,13 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool: # Same platform-aware detach for the watcher process itself — so # closing the user's terminal doesn't kill the watcher. subprocess.Popen( - [sys.executable, "-c", watcher, str(old_pid), *_gateway_run_args_for_profile(profile)], + [ + sys.executable, + "-c", + watcher, + str(old_pid), + *_gateway_run_args_for_profile(profile), + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **windows_detach_popen_kwargs(), @@ -693,7 +753,7 @@ def _read_systemd_unit_environment(system: bool = False) -> dict[str, str]: for line in result.stdout.splitlines(): if not line.startswith("Environment="): continue - body = line[len("Environment="):].strip() + body = line[len("Environment=") :].strip() for token in body.split(): if "=" not in token: continue @@ -835,11 +895,17 @@ def _wait_for_systemd_service_restart( print(f"✓ {scope_label} service restarted (PID {new_pid})") return True if gateway_state == "startup_failed": - reason = (runtime_state or {}).get("exit_reason") or "startup failed" - print(f"⚠ {scope_label} service process restarted (PID {new_pid}), but gateway startup failed: {reason}") + reason = (runtime_state or {}).get( + "exit_reason" + ) or "startup failed" + print( + f"⚠ {scope_label} service process restarted (PID {new_pid}), but gateway startup failed: {reason}" + ) return False if not printed_runtime_wait: - print(f"⏳ {scope_label} service process started (PID {new_pid}); waiting for gateway runtime...") + print( + f"⏳ {scope_label} service process started (PID {new_pid}); waiting for gateway runtime..." + ) printed_runtime_wait = True if active_state == "activating" and sub_state == "auto-restart": @@ -895,12 +961,16 @@ def _print_systemd_start_limit_wait(system: bool = False) -> None: journal_prefix = "journalctl " if system else "journalctl --user " print(f"⏳ {scope_label} service is temporarily rate-limited by systemd.") print(" systemd is refusing another immediate start after repeated exits.") - print(f" Wait for the start-limit window to expire, then run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}") + print( + f" Wait for the start-limit window to expire, then run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}" + ) print(f" Or clear the failed state manually: {systemctl_prefix}reset-failed {svc}") print(f" Check logs: {journal_prefix}-u {svc} -l --since '5 min ago'") -def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | None = None) -> bool: +def _recover_pending_systemd_restart( + system: bool = False, previous_pid: int | None = None +) -> bool: """Recover a planned service restart that is stuck in systemd state.""" props = _read_systemd_unit_properties(system=system) if not props: @@ -933,7 +1003,9 @@ def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | N ): svc = get_service_name() scope_label = _service_scope_label(system).capitalize() - print(f"↻ Clearing failed state for pending {scope_label.lower()} service restart...") + print( + f"↻ Clearing failed state for pending {scope_label.lower()} service restart..." + ) _run_systemctl( ["reset-failed", svc], system=system, @@ -1024,8 +1096,14 @@ def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot ) -def _format_gateway_pids(pids: tuple[int, ...] | list[int], *, limit: int | None = 3) -> str: - rendered = [str(pid) for pid in pids[:limit] if pid > 0] if limit is not None else [str(pid) for pid in pids if pid > 0] +def _format_gateway_pids( + pids: tuple[int, ...] | list[int], *, limit: int | None = 3 +) -> str: + rendered = ( + [str(pid) for pid in pids[:limit] if pid > 0] + if limit is not None + else [str(pid) for pid in pids if pid > 0] + ) if limit is not None and len(pids) > limit: rendered.append("...") return ", ".join(rendered) @@ -1035,7 +1113,9 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None: if not snapshot.has_process_service_mismatch: return print() - print("⚠ Gateway process is running for this profile, but the service is not active") + print( + "⚠ Gateway process is running for this profile, but the service is not active" + ) print(f" PID(s): {_format_gateway_pids(snapshot.gateway_pids, limit=None)}") print(" This is usually a manual foreground/tmux/nohup run, so `hermes gateway`") print(" can refuse to start another copy until this process stops.") @@ -1053,8 +1133,7 @@ def _print_other_profiles_gateway_status() -> None: current = get_active_profile_name() other_processes = [ - p for p in find_profile_gateway_processes() - if p.profile != current + p for p in find_profile_gateway_processes() if p.profile != current ] if not other_processes: return @@ -1097,6 +1176,7 @@ def _gateway_list() -> None: if prof.gateway_running: try: from gateway.status import get_running_pid + pid = get_running_pid(prof.path / "gateway.pid", cleanup_stale=False) if pid: parts.append(f"PID {pid}") @@ -1107,8 +1187,9 @@ def _gateway_list() -> None: print(" — ".join(parts)) -def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None, - all_profiles: bool = False) -> int: +def kill_gateway_processes( + force: bool = False, exclude_pids: set | None = None, all_profiles: bool = False +) -> int: """Kill any running gateway processes. Returns count killed. Args: @@ -1120,7 +1201,7 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None, """ pids = find_gateway_pids(exclude_pids=exclude_pids, all_profiles=all_profiles) killed = 0 - + for pid in pids: try: terminate_pid(pid, force=force) @@ -1130,7 +1211,7 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None, pass except PermissionError: print(f"⚠ Permission denied to kill PID {pid}") - + except OSError as exc: print(f"Failed to kill PID {pid}: {exc}") return killed @@ -1154,6 +1235,7 @@ def stop_profile_gateway() -> bool: try: from gateway.status import write_planned_stop_marker + write_planned_stop_marker(pid) except Exception: pass @@ -1170,6 +1252,7 @@ def stop_profile_gateway() -> bool: # a no-op — route through the cross-platform existence check. import time as _time from gateway.status import _pid_exists + for _ in range(20): if not _pid_exists(pid): break @@ -1181,7 +1264,7 @@ def stop_profile_gateway() -> bool: def is_linux() -> bool: - return sys.platform.startswith('linux') + return sys.platform.startswith("linux") from hermes_constants import is_container, is_termux, is_wsl @@ -1245,10 +1328,11 @@ def supports_systemd_services() -> bool: def is_macos() -> bool: - return sys.platform == 'darwin' + return sys.platform == "darwin" + def is_windows() -> bool: - return sys.platform == 'win32' + return sys.platform == "win32" def _windows_gateway_should_absorb_console_controls() -> bool: @@ -1290,6 +1374,7 @@ def _profile_suffix() -> str: import hashlib import re from hermes_constants import get_default_hermes_root + home = get_hermes_home().resolve() default = get_default_hermes_root().resolve() if home == default: @@ -1320,6 +1405,7 @@ def _profile_arg(hermes_home: str | None = None) -> str: """ import re from hermes_constants import get_default_hermes_root + home = Path(hermes_home or str(get_hermes_home())).resolve() default = get_default_hermes_root().resolve() if home == default: @@ -1348,7 +1434,6 @@ def get_service_name() -> str: return f"{_SERVICE_BASE}-{suffix}" - def get_systemd_unit_path(system: bool = False) -> Path: name = get_service_name() if system: @@ -1405,7 +1490,10 @@ def _user_systemd_socket_ready() -> bool: D-Bus session bus socket is absent. ``systemctl --user`` can still work in that configuration, so preflight checks must treat either socket as valid. """ - return _user_dbus_socket_path().exists() or _user_systemd_private_socket_path().exists() + return ( + _user_dbus_socket_path().exists() + or _user_systemd_private_socket_path().exists() + ) def _ensure_user_systemd_env() -> None: @@ -1517,7 +1605,9 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None: f" Or reboot and run: systemctl --user start {get_service_name()}" ), ) - detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip() + detail = ( + result.stderr or result.stdout or f"exit {result.returncode}" + ).strip() _raise_user_systemd_unavailable( username, reason=f"loginctl enable-linger was denied: {detail}", @@ -1534,7 +1624,9 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None: ) -def _raise_user_systemd_unavailable(username: str, *, reason: str, fix_hint: str) -> None: +def _raise_user_systemd_unavailable( + username: str, *, reason: str, fix_hint: str +) -> None: """Build a user-facing error message and raise UserSystemdUnavailableError.""" msg = ( f"{reason}\n" @@ -1560,7 +1652,9 @@ def _journalctl_cmd(system: bool = False) -> list[str]: return ["journalctl"] if system else ["journalctl", "--user"] -def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subprocess.CompletedProcess: +def _run_systemctl( + args: list[str], *, system: bool = False, **kwargs +) -> subprocess.CompletedProcess: """Run a systemctl command, raising RuntimeError if systemctl is missing. Defense-in-depth: callers are gated by ``supports_systemd_services()``, @@ -1570,9 +1664,7 @@ def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subpro try: return subprocess.run(_systemctl_cmd(system) + args, **kwargs) except FileNotFoundError: - raise RuntimeError( - "systemctl is not available on this system" - ) from None + raise RuntimeError("systemctl is not available on this system") from None def _service_scope_label(system: bool = False) -> str: @@ -1763,7 +1855,9 @@ def remove_legacy_hermes_units( for name, path in system_units: try: _run_systemctl(["stop", name], system=True, check=False, timeout=90) - _run_systemctl(["disable", name], system=True, check=False, timeout=30) + _run_systemctl( + ["disable", name], system=True, check=False, timeout=30 + ) path.unlink(missing_ok=True) print(f" ✓ Removed {path}") removed += 1 @@ -1778,7 +1872,9 @@ def remove_legacy_hermes_units( print() if remaining: - print_warning(f"{len(remaining)} legacy unit(s) still present — see messages above.") + print_warning( + f"{len(remaining)} legacy unit(s) still present — see messages above." + ) else: print_success(f"Removed {removed} legacy unit(s).") @@ -1791,9 +1887,13 @@ def print_systemd_scope_conflict_warning() -> None: return rendered_scopes = " + ".join(scopes) - print_warning(f"Both user and system gateway services are installed ({rendered_scopes}).") + print_warning( + f"Both user and system gateway services are installed ({rendered_scopes})." + ) print_info(" This is confusing and can make start/stop/status behavior ambiguous.") - print_info(" Default gateway commands target the user service unless you pass --system.") + print_info( + " Default gateway commands target the user service unless you pass --system." + ) print_info(" Keep one of these:") print_info(" hermes gateway uninstall") print_info(" sudo hermes gateway uninstall --system") @@ -1812,14 +1912,26 @@ def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str, import grp import pwd - username = (run_as_user or os.getenv("SUDO_USER") or os.getenv("USER") or os.getenv("LOGNAME") or getpass.getuser()).strip() + username = ( + run_as_user + or os.getenv("SUDO_USER") + or os.getenv("USER") + or os.getenv("LOGNAME") + or getpass.getuser() + ).strip() if not username: - raise ValueError("Could not determine which user the gateway service should run as") + raise ValueError( + "Could not determine which user the gateway service should run as" + ) if username == "root" and not run_as_user: - raise ValueError("Refusing to install the gateway system service as root; pass --run-as-user root to override (e.g. in LXC containers)") + raise ValueError( + "Refusing to install the gateway system service as root; pass --run-as-user root to override (e.g. in LXC containers)" + ) if username == "root": print_warning("Installing gateway service to run as root.") - print_info(" This is fine for LXC/container environments but not recommended on bare-metal hosts.") + print_info( + " This is fine for LXC/container environments but not recommended on bare-metal hosts." + ) try: user_info = pwd.getpwnam(username) @@ -1869,17 +1981,25 @@ def install_linux_gateway_from_setup(force: bool = False, enable_on_startup: boo if scope == "system": run_as_user = _default_system_service_user() if os.geteuid() != 0: # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows - print_warning(" System service install requires sudo, so Hermes can't create it from this user session.") + print_warning( + " System service install requires sudo, so Hermes can't create it from this user session." + ) if run_as_user: - print_info(f" After setup, run: sudo hermes gateway install --system --run-as-user {run_as_user}") + print_info( + f" After setup, run: sudo hermes gateway install --system --run-as-user {run_as_user}" + ) else: - print_info(" After setup, run: sudo hermes gateway install --system --run-as-user <your-user>") + print_info( + " After setup, run: sudo hermes gateway install --system --run-as-user <your-user>" + ) print_info(" Then start it with: sudo hermes gateway start --system") return scope, False if not run_as_user: while True: - run_as_user = prompt(" Run the system gateway service as which user?", default="") + run_as_user = prompt( + " Run the system gateway service as which user?", default="" + ) run_as_user = (run_as_user or "").strip() if run_as_user: break @@ -1912,6 +2032,7 @@ def get_systemd_linger_status() -> tuple[bool | None, str]: if not username: try: import pwd + username = pwd.getpwuid(os.getuid()).pw_name # windows-footgun: ok — POSIX loginctl helper, never invoked on Windows except Exception: return None, "could not determine current user" @@ -1954,6 +2075,7 @@ def print_systemd_linger_guidance() -> None: print(" If you want the gateway user service to survive logout, run:") print(" sudo loginctl enable-linger $USER") + def _launchd_user_home() -> Path: """Return the real macOS user home for launchd artifacts. @@ -1975,6 +2097,7 @@ def get_launchd_plist_path() -> Path: name = f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway" return _launchd_user_home() / "Library" / "LaunchAgents" / f"{name}.plist" + def _detect_venv_dir() -> Path | None: """Detect the active virtualenv directory. @@ -2024,13 +2147,14 @@ def get_python_path() -> str: # Systemd (Linux) # ============================================================================= + def _build_user_local_paths(home: Path, path_entries: list[str]) -> list[str]: """Return user-local bin dirs that exist and aren't already in *path_entries*.""" candidates = [ - str(home / ".local" / "bin"), # uv, uvx, pip-installed CLIs - str(home / ".cargo" / "bin"), # Rust/cargo tools - str(home / "go" / "bin"), # Go tools - str(home / ".npm-global" / "bin"), # npm global packages + str(home / ".local" / "bin"), # uv, uvx, pip-installed CLIs + str(home / ".cargo" / "bin"), # Rust/cargo tools + str(home / "go" / "bin"), # Go tools + str(home / ".npm-global" / "bin"), # npm global packages ] return [p for p in candidates if p not in path_entries and Path(p).exists()] @@ -2202,7 +2326,14 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) if resolved_node_dir not in path_entries: path_entries.append(resolved_node_dir) - common_bin_paths = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"] + common_bin_paths = [ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + ] # systemd's TimeoutStopSec must exceed the gateway's drain_timeout so # there's budget left for post-interrupt cleanup (tool subprocess kill, # adapter disconnect, session DB close) before systemd escalates to @@ -2299,6 +2430,7 @@ StandardError=journal WantedBy=default.target """ + def _normalize_service_definition(text: str) -> str: return "\n".join(line.rstrip() for line in text.strip().splitlines()) @@ -2315,8 +2447,8 @@ def _normalize_launchd_plist_for_comparison(text: str) -> str: normalized = _normalize_service_definition(text) return re.sub( - r'(<key>PATH</key>\s*<string>)(.*?)(</string>)', - r'\1__HERMES_PATH__\3', + r"(<key>PATH</key>\s*<string>)(.*?)(</string>)", + r"\1__HERMES_PATH__\3", normalized, flags=re.S, ) @@ -2330,8 +2462,9 @@ def systemd_unit_is_current(system: bool = False) -> bool: installed = unit_path.read_text(encoding="utf-8") expected_user = _read_systemd_user_from_unit(unit_path) if system else None expected = generate_systemd_unit(system=system, run_as_user=expected_user) - return _normalize_service_definition(installed) == _normalize_service_definition(expected) - + return _normalize_service_definition(installed) == _normalize_service_definition( + expected + ) def refresh_systemd_unit_if_needed(system: bool = False) -> bool: @@ -2359,18 +2492,19 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool: # still works. if not system and ( "/pytest-of-" in new_unit - or "/hermes_test\"" in new_unit + or '/hermes_test"' in new_unit or "/hermes_test/" in new_unit ): return False unit_path.write_text(new_unit, encoding="utf-8") _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) - print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install") + print( + f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install" + ) return True - def _print_linger_enable_warning(username: str, detail: str | None = None) -> None: print() print("⚠ Linger not enabled — gateway may stop when you close this terminal.") @@ -2385,7 +2519,6 @@ def _print_linger_enable_warning(username: str, detail: str | None = None) -> No print() - def _ensure_linger_enabled() -> None: """Enable linger when possible so the user gateway survives logout.""" if is_termux() or not is_linux(): @@ -2432,7 +2565,10 @@ def _ensure_linger_enabled() -> None: def _select_systemd_scope(system: bool = False) -> bool: if system: return True - return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists() + return ( + get_systemd_unit_path(system=True).exists() + and not get_systemd_unit_path(system=False).exists() + ) def _system_scope_wizard_would_need_root(system: bool = False) -> bool: @@ -2457,8 +2593,7 @@ def _print_system_scope_remediation(action: str) -> None: """ svc = get_service_name() print_warning( - f"Gateway is installed as a system-wide service — " - f"{action} requires root." + f"Gateway is installed as a system-wide service — " f"{action} requires root." ) print_info(" Options:") print_info(f" 1. {action.capitalize()} it this time:") @@ -2517,7 +2652,9 @@ def systemd_install( if unit_path.exists() and not force: if not systemd_unit_is_current(system=system): - print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}") + print( + f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}" + ) refresh_systemd_unit_if_needed(system=system) if enable_on_startup: _run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30) @@ -2529,7 +2666,9 @@ def systemd_install( unit_path.parent.mkdir(parents=True, exist_ok=True) print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}") - unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8") + unit_path.write_text( + generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8" + ) _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) if enable_on_startup: @@ -2540,9 +2679,15 @@ def systemd_install( print(f"✓ {_service_scope_label(system).capitalize()} service {enable_label}!") print() print("Next steps:") - print(f" {'sudo ' if system else ''}hermes gateway start{scope_flag} # Start the service") - print(f" {'sudo ' if system else ''}hermes gateway status{scope_flag} # Check status") - print(f" {'journalctl' if system else 'journalctl --user'} -u {get_service_name()} -f # View logs") + print( + f" {'sudo ' if system else ''}hermes gateway start{scope_flag} # Start the service" + ) + print( + f" {'sudo ' if system else ''}hermes gateway status{scope_flag} # Check status" + ) + print( + f" {'journalctl' if system else 'journalctl --user'} -u {get_service_name()} -f # View logs" + ) print() if system: @@ -2562,7 +2707,9 @@ def systemd_uninstall(system: bool = False): _require_root_for_system_service("uninstall") _run_systemctl(["stop", get_service_name()], system=system, check=False, timeout=90) - _run_systemctl(["disable", get_service_name()], system=system, check=False, timeout=30) + _run_systemctl( + ["disable", get_service_name()], system=system, check=False, timeout=30 + ) unit_path = get_systemd_unit_path(system=system) if unit_path.exists(): @@ -2597,7 +2744,6 @@ def systemd_start(system: bool = False): print(f"✓ {_service_scope_label(system).capitalize()} service started") - def systemd_stop(system: bool = False): system = _select_systemd_scope(system) if system: @@ -2606,13 +2752,16 @@ def systemd_stop(system: bool = False): _sync_hermes_home_from_systemd_unit(system=system) try: from gateway.status import get_running_pid, write_planned_stop_marker + pid = get_running_pid(cleanup_stale=False) if pid is not None: write_planned_stop_marker(pid) except Exception: pass try: - _run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90) + _run_systemctl( + ["stop", get_service_name()], system=system, check=True, timeout=90 + ) except subprocess.TimeoutExpired: label = _service_scope_label(system) print( @@ -2623,7 +2772,6 @@ def systemd_stop(system: bool = False): print(f"✓ {_service_scope_label(system).capitalize()} service stopped") - def systemd_restart(system: bool = False): system = _select_systemd_scope(system) if system: @@ -2677,7 +2825,9 @@ def systemd_restart(system: bool = False): try: _run_systemctl(["restart", svc], system=system, check=True, timeout=90) except subprocess.CalledProcessError as exc: - if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system): + if _systemd_error_indicates_start_limit( + exc + ) or _systemd_service_is_start_limited(system=system): _print_systemd_start_limit_wait(system=system) return raise @@ -2701,9 +2851,13 @@ def systemd_restart(system: bool = False): timeout=30, ) try: - _run_systemctl(["restart", get_service_name()], system=system, check=True, timeout=90) + _run_systemctl( + ["restart", get_service_name()], system=system, check=True, timeout=90 + ) except subprocess.CalledProcessError as exc: - if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system): + if _systemd_error_indicates_start_limit( + exc + ) or _systemd_service_is_start_limited(system=system): _print_systemd_start_limit_wait(system=system) return raise @@ -2717,7 +2871,6 @@ def systemd_restart(system: bool = False): _wait_for_systemd_service_restart(system=system, previous_pid=pid) - def systemd_status(deep: bool = False, system: bool = False, full: bool = False): system = _select_systemd_scope(system) unit_path = get_systemd_unit_path(system=system) @@ -2740,7 +2893,9 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) if not systemd_unit_is_current(system=system): print("⚠ Installed gateway service definition is outdated") - print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit") + print( + f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit" + ) print() status_cmd = ["status", get_service_name(), "--no-pager"] @@ -2765,9 +2920,13 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) status = result.stdout.strip() if status == "active": - print(f"✓ {_service_scope_label(system).capitalize()} gateway service is running") + print( + f"✓ {_service_scope_label(system).capitalize()} gateway service is running" + ) else: - print(f"✗ {_service_scope_label(system).capitalize()} gateway service is stopped") + print( + f"✗ {_service_scope_label(system).capitalize()} gateway service is stopped" + ) print(f" Run: {'sudo ' if system else ''}hermes gateway start{scope_flag}") configured_user = _read_systemd_user_from_unit(unit_path) if system else None @@ -2790,11 +2949,19 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) print(" ⏳ Restart pending: systemd is waiting to relaunch the gateway") elif _systemd_unit_is_start_limited(unit_props): print(" ⏳ Restart pending: systemd is temporarily rate-limiting starts") - print(f" Run after the start-limit window expires: {'sudo ' if system else ''}hermes gateway restart{scope_flag}") - print(f" Or clear it manually: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()}") - elif active_state == "failed" and exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE): + print( + f" Run after the start-limit window expires: {'sudo ' if system else ''}hermes gateway restart{scope_flag}" + ) + print( + f" Or clear it manually: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()}" + ) + elif active_state == "failed" and exec_main_status == str( + GATEWAY_SERVICE_RESTART_EXIT_CODE + ): print(" ⚠ Planned restart is stuck in systemd failed state (exit 75)") - print(f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}") + print( + f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}" + ) elif active_state == "failed" and result_code: print(f" ⚠ Systemd unit result: {result_code}") @@ -2813,7 +2980,13 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) if deep: print() print("Recent logs:") - log_cmd = _journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"] + log_cmd = _journalctl_cmd(system) + [ + "-u", + get_service_name(), + "-n", + "20", + "--no-pager", + ] if full: log_cmd.append("-l") subprocess.run(log_cmd, timeout=10) @@ -2823,6 +2996,7 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) # Launchd (macOS) # ============================================================================= + def get_launchd_label() -> str: """Return the launchd service label, scoped per profile.""" suffix = _profile_suffix() @@ -2860,7 +3034,9 @@ def generate_launchd_plist() -> str: if resolved_node_dir not in priority_dirs: priority_dirs.append(resolved_node_dir) sane_path = ":".join( - dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p]) + dict.fromkeys( + priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p] + ) ) # Build ProgramArguments array, including --profile when using a named profile @@ -2872,11 +3048,13 @@ def generate_launchd_plist() -> str: if profile_arg: for part in profile_arg.split(): prog_args.append(f"<string>{part}</string>") - prog_args.extend([ - "<string>gateway</string>", - "<string>run</string>", - "<string>--replace</string>", - ]) + prog_args.extend( + [ + "<string>gateway</string>", + "<string>run</string>", + "<string>--replace</string>", + ] + ) prog_args_xml = "\n ".join(prog_args) return f"""<?xml version="1.0" encoding="UTF-8"?> @@ -2922,6 +3100,7 @@ def generate_launchd_plist() -> str: </plist> """ + def launchd_plist_is_current() -> bool: """Check if the installed launchd plist matches the currently generated one.""" plist_path = get_launchd_plist_path() @@ -2930,7 +3109,9 @@ def launchd_plist_is_current() -> bool: installed = plist_path.read_text(encoding="utf-8") expected = generate_launchd_plist() - return _normalize_launchd_plist_for_comparison(installed) == _normalize_launchd_plist_for_comparison(expected) + return _normalize_launchd_plist_for_comparison( + installed + ) == _normalize_launchd_plist_for_comparison(expected) def refresh_launchd_plist_if_needed() -> bool: @@ -2947,15 +3128,25 @@ def refresh_launchd_plist_if_needed() -> bool: plist_path.write_text(generate_launchd_plist(), encoding="utf-8") label = get_launchd_label() # Bootout/bootstrap so launchd picks up the new definition - subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90) - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=False, timeout=30) - print("↻ Updated gateway launchd service definition to match the current Hermes install") + subprocess.run( + ["launchctl", "bootout", f"{_launchd_domain()}/{label}"], + check=False, + timeout=90, + ) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=False, + timeout=30, + ) + print( + "↻ Updated gateway launchd service definition to match the current Hermes install" + ) return True def launchd_install(force: bool = False): plist_path = get_launchd_plist_path() - + if plist_path.exists() and not force: if not launchd_plist_is_current(): print(f"↻ Repairing outdated launchd service at: {plist_path}") @@ -2965,32 +3156,43 @@ def launchd_install(force: bool = False): print(f"Service already installed at: {plist_path}") print("Use --force to reinstall") return - + plist_path.parent.mkdir(parents=True, exist_ok=True) print(f"Installing launchd service to: {plist_path}") plist_path.write_text(generate_launchd_plist()) - - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) - + + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) + print() print("✓ Service installed and loaded!") print() print("Next steps:") print(" hermes gateway status # Check status") from hermes_constants import display_hermes_home as _dhh + print(f" tail -f {_dhh()}/logs/gateway.log # View logs") + def launchd_uninstall(): plist_path = get_launchd_plist_path() label = get_launchd_label() - subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90) - + subprocess.run( + ["launchctl", "bootout", f"{_launchd_domain()}/{label}"], + check=False, + timeout=90, + ) + if plist_path.exists(): plist_path.unlink() print(f"✓ Removed {plist_path}") - + print("✓ Service uninstalled") + def launchd_start(): plist_path = get_launchd_plist_path() label = get_launchd_label() @@ -3000,27 +3202,49 @@ def launchd_start(): print("↻ launchd plist missing; regenerating service definition") plist_path.parent.mkdir(parents=True, exist_ok=True) plist_path.write_text(generate_launchd_plist(), encoding="utf-8") - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) - subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) + subprocess.run( + ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], + check=True, + timeout=30, + ) print("✓ Service started") return refresh_launchd_plist_if_needed() try: - subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) + subprocess.run( + ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], + check=True, + timeout=30, + ) except subprocess.CalledProcessError as e: if e.returncode not in {3, 113}: raise print("↻ launchd job was unloaded; reloading service definition") - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) - subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) + subprocess.run( + ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], + check=True, + timeout=30, + ) print("✓ Service started") + def launchd_stop(): label = get_launchd_label() target = f"{_launchd_domain()}/{label}" try: from gateway.status import get_running_pid, write_planned_stop_marker + pid = get_running_pid(cleanup_stale=False) if pid is not None: write_planned_stop_marker(pid) @@ -3040,7 +3264,10 @@ def launchd_stop(): _wait_for_gateway_exit(timeout=10.0, force_after=5.0) print("✓ Service stopped") -def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.0) -> bool: + +def _wait_for_gateway_exit( + timeout: float = 10.0, force_after: float | None = 5.0 +) -> bool: """Wait for the gateway process (by saved PID) to exit. Uses the PID from the gateway.pid file — not launchd labels — so this @@ -3055,7 +3282,9 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5. from gateway.status import get_running_pid deadline = time.monotonic() + timeout - force_deadline = (time.monotonic() + force_after) if force_after is not None else None + force_deadline = ( + (time.monotonic() + force_after) if force_after is not None else None + ) force_sent = False while time.monotonic() < deadline: @@ -3063,7 +3292,11 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5. if pid is None: return True # Process exited cleanly. - if force_after is not None and not force_sent and time.monotonic() >= force_deadline: + if ( + force_after is not None + and not force_sent + and time.monotonic() >= force_deadline + ): # Grace period expired — force-kill the specific PID. try: terminate_pid(pid, force=True) @@ -3077,7 +3310,9 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5. # Timed out even after force-kill. remaining_pid = get_running_pid() if remaining_pid is not None: - print(f"⚠ Gateway PID {remaining_pid} still running after {timeout}s — restart may fail") + print( + f"⚠ Gateway PID {remaining_pid} still running after {timeout}s — restart may fail" + ) return False return True @@ -3101,7 +3336,9 @@ def launchd_restart(): if pid is not None: exited = _wait_for_gateway_exit(timeout=drain_timeout, force_after=None) if not exited: - print(f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart") + print( + f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart" + ) subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) print("✓ Service restarted") except subprocess.CalledProcessError as e: @@ -3110,10 +3347,15 @@ def launchd_restart(): # Job not loaded — bootstrap and start fresh print("↻ launchd job was unloaded; reloading") plist_path = get_launchd_plist_path() - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30) print("✓ Service restarted") + def launchd_status(deep: bool = False): plist_path = get_launchd_plist_path() label = get_launchd_label() @@ -3144,7 +3386,7 @@ def launchd_status(deep: bool = False): print("✗ Gateway service is not loaded") print(" Service definition exists locally but launchd has not loaded it.") print(" Run: hermes gateway start") - + if deep: log_file = get_hermes_home() / "logs" / "gateway.log" if log_file.exists(): @@ -3157,6 +3399,7 @@ def launchd_status(deep: bool = False): # Gateway Runner # ============================================================================= + def _truthy_env(value: str | None) -> bool: return str(value or "").strip().lower() in {"1", "true", "yes", "on"} @@ -3189,13 +3432,15 @@ def _guard_official_docker_root_gateway() -> None: " Running the gateway as root can leave root-owned files in " "$HERMES_HOME and break later non-root dashboard/gateway runs." ) - print(" Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk.") + print( + " Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk." + ) sys.exit(1) def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): """Run the gateway in foreground. - + Args: verbose: Stderr log verbosity count added on top of default WARNING (0=WARNING, 1=INFO, 2+=DEBUG). quiet: Suppress all stderr log output. @@ -3235,6 +3480,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): # handlers above. try: import ctypes + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # BOOL SetConsoleCtrlHandler(NULL, Add) — Add=TRUE means # "install the NULL handler", which has the documented @@ -3258,9 +3504,9 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): refresh_systemd_unit_if_needed(system=False) except Exception: pass # best-effort; don't block gateway startup - + from gateway.run import start_gateway - + print("┌─────────────────────────────────────────────────────────┐") print("│ ⚕ Hermes Gateway Starting... │") print("├─────────────────────────────────────────────────────────┤") @@ -3268,7 +3514,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): print("│ Press Ctrl+C to stop │") print("└─────────────────────────────────────────────────────────┘") print() - + # Exit with code 1 if gateway fails to connect any platform, # so systemd Restart=always will retry on transient errors verbosity = None if quiet else verbose @@ -3292,6 +3538,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): return try: from hermes_constants import get_hermes_home as _ghh + log_dir = _ghh() / "logs" log_dir.mkdir(parents=True, exist_ok=True) ts = _dt.now(_tz.utc).isoformat() @@ -3304,6 +3551,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): **extra, } import json as _json + with open(log_dir / "gateway-exit-diag.log", "a", encoding="utf-8") as f: f.write(_json.dumps(line, default=str) + "\n") except Exception: @@ -3336,8 +3584,11 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): print("\nGateway stopped.") return except SystemExit as e: - _exit_diag("asyncio.run.SystemExit", code=getattr(e, "code", None), - traceback=_traceback.format_exc()) + _exit_diag( + "asyncio.run.SystemExit", + code=getattr(e, "code", None), + traceback=_traceback.format_exc(), + ) raise except BaseException as e: # Absolutely everything else: Exception, asyncio.CancelledError, @@ -3374,13 +3625,25 @@ _PLATFORMS = [ "4. To find your user ID: message @userinfobot — it replies with your numeric ID", ], "vars": [ - {"name": "TELEGRAM_BOT_TOKEN", "prompt": "Bot token", "password": True, - "help": "Paste the token from @BotFather (step 3 above)."}, - {"name": "TELEGRAM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Paste your user ID from step 4 above."}, - {"name": "TELEGRAM_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."}, + { + "name": "TELEGRAM_BOT_TOKEN", + "prompt": "Bot token", + "password": True, + "help": "Paste the token from @BotFather (step 3 above).", + }, + { + "name": "TELEGRAM_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Paste your user ID from step 4 above.", + }, + { + "name": "TELEGRAM_HOME_CHANNEL", + "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", + "password": False, + "help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat.", + }, ], }, # Discord moved to plugins/platforms/discord/ — its setup metadata is @@ -3408,13 +3671,25 @@ _PLATFORMS = [ "8. Invite the bot to channels: /invite @YourBot", ], "vars": [ - {"name": "SLACK_BOT_TOKEN", "prompt": "Bot Token (xoxb-...)", "password": True, - "help": "Paste the bot token from step 3 above."}, - {"name": "SLACK_APP_TOKEN", "prompt": "App Token (xapp-...)", "password": True, - "help": "Paste the app-level token from step 4 above."}, - {"name": "SLACK_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Paste your member ID from step 7 above."}, + { + "name": "SLACK_BOT_TOKEN", + "prompt": "Bot Token (xoxb-...)", + "password": True, + "help": "Paste the bot token from step 3 above.", + }, + { + "name": "SLACK_APP_TOKEN", + "prompt": "App Token (xapp-...)", + "password": True, + "help": "Paste the app-level token from step 4 above.", + }, + { + "name": "SLACK_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Paste your member ID from step 7 above.", + }, ], }, { @@ -3427,23 +3702,43 @@ _PLATFORMS = [ "2. Create a bot user on your homeserver, or use your own account", "3. Get an access token: Element → Settings → Help & About → Access Token", " Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\", - " -d '{\"type\":\"m.login.password\",\"user\":\"@bot:server\",\"password\":\"...\"}'", + ' -d \'{"type":"m.login.password","user":"@bot:server","password":"..."}\'', "4. Alternatively, provide user ID + password and Hermes will log in directly", "5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'mautrix[encryption]')", "6. To find your user ID: it's @username:your-server (shown in Element profile)", ], "vars": [ - {"name": "MATRIX_HOMESERVER", "prompt": "Homeserver URL (e.g. https://matrix.example.org)", "password": False, - "help": "Your Matrix homeserver URL. Works with any self-hosted instance."}, - {"name": "MATRIX_ACCESS_TOKEN", "prompt": "Access token (leave empty to use password login instead)", "password": True, - "help": "Paste your access token, or leave empty and provide user ID + password below."}, - {"name": "MATRIX_USER_ID", "prompt": "User ID (@bot:server — required for password login)", "password": False, - "help": "Full Matrix user ID, e.g. @hermes:matrix.example.org"}, - {"name": "MATRIX_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, e.g. @you:server)", "password": False, - "is_allowlist": True, - "help": "Matrix user IDs who can interact with the bot."}, - {"name": "MATRIX_HOME_ROOM", "prompt": "Home room ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "Room ID (e.g. !abc123:server) for delivering cron results and notifications."}, + { + "name": "MATRIX_HOMESERVER", + "prompt": "Homeserver URL (e.g. https://matrix.example.org)", + "password": False, + "help": "Your Matrix homeserver URL. Works with any self-hosted instance.", + }, + { + "name": "MATRIX_ACCESS_TOKEN", + "prompt": "Access token (leave empty to use password login instead)", + "password": True, + "help": "Paste your access token, or leave empty and provide user ID + password below.", + }, + { + "name": "MATRIX_USER_ID", + "prompt": "User ID (@bot:server — required for password login)", + "password": False, + "help": "Full Matrix user ID, e.g. @hermes:matrix.example.org", + }, + { + "name": "MATRIX_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, e.g. @you:server)", + "password": False, + "is_allowlist": True, + "help": "Matrix user IDs who can interact with the bot.", + }, + { + "name": "MATRIX_HOME_ROOM", + "prompt": "Home room ID (for cron/notification delivery, or empty to set later with /set-home)", + "password": False, + "help": "Room ID (e.g. !abc123:server) for delivering cron results and notifications.", + }, ], }, { @@ -3462,17 +3757,37 @@ _PLATFORMS = [ "5. To get a channel ID: click the channel name → View Info → copy the ID", ], "vars": [ - {"name": "MATTERMOST_URL", "prompt": "Server URL (e.g. https://mm.example.com)", "password": False, - "help": "Your Mattermost server URL. Works with any self-hosted instance."}, - {"name": "MATTERMOST_TOKEN", "prompt": "Bot token", "password": True, - "help": "Paste the bot token from step 2 above."}, - {"name": "MATTERMOST_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Your Mattermost user ID from step 4 above."}, - {"name": "MATTERMOST_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "Channel ID where Hermes delivers cron results and notifications."}, - {"name": "MATTERMOST_REPLY_MODE", "prompt": "Reply mode — 'off' for flat messages, 'thread' for threaded replies (default: off)", "password": False, - "help": "off = flat channel messages, thread = replies nest under your message."}, + { + "name": "MATTERMOST_URL", + "prompt": "Server URL (e.g. https://mm.example.com)", + "password": False, + "help": "Your Mattermost server URL. Works with any self-hosted instance.", + }, + { + "name": "MATTERMOST_TOKEN", + "prompt": "Bot token", + "password": True, + "help": "Paste the bot token from step 2 above.", + }, + { + "name": "MATTERMOST_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Your Mattermost user ID from step 4 above.", + }, + { + "name": "MATTERMOST_HOME_CHANNEL", + "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", + "password": False, + "help": "Channel ID where Hermes delivers cron results and notifications.", + }, + { + "name": "MATTERMOST_REPLY_MODE", + "prompt": "Reply mode — 'off' for flat messages, 'thread' for threaded replies (default: off)", + "password": False, + "help": "off = flat channel messages, thread = replies nest under your message.", + }, ], }, { @@ -3500,17 +3815,37 @@ _PLATFORMS = [ "4. IMAP must be enabled on your email account", ], "vars": [ - {"name": "EMAIL_ADDRESS", "prompt": "Email address", "password": False, - "help": "The email address Hermes will use (e.g., hermes@gmail.com)."}, - {"name": "EMAIL_PASSWORD", "prompt": "Email password (or app password)", "password": True, - "help": "For Gmail, use an App Password (not your regular password)."}, - {"name": "EMAIL_IMAP_HOST", "prompt": "IMAP host", "password": False, - "help": "e.g., imap.gmail.com for Gmail, outlook.office365.com for Outlook."}, - {"name": "EMAIL_SMTP_HOST", "prompt": "SMTP host", "password": False, - "help": "e.g., smtp.gmail.com for Gmail, smtp.office365.com for Outlook."}, - {"name": "EMAIL_ALLOWED_USERS", "prompt": "Allowed sender emails (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Only emails from these addresses will be processed."}, + { + "name": "EMAIL_ADDRESS", + "prompt": "Email address", + "password": False, + "help": "The email address Hermes will use (e.g., hermes@gmail.com).", + }, + { + "name": "EMAIL_PASSWORD", + "prompt": "Email password (or app password)", + "password": True, + "help": "For Gmail, use an App Password (not your regular password).", + }, + { + "name": "EMAIL_IMAP_HOST", + "prompt": "IMAP host", + "password": False, + "help": "e.g., imap.gmail.com for Gmail, outlook.office365.com for Outlook.", + }, + { + "name": "EMAIL_SMTP_HOST", + "prompt": "SMTP host", + "password": False, + "help": "e.g., smtp.gmail.com for Gmail, smtp.office365.com for Outlook.", + }, + { + "name": "EMAIL_ALLOWED_USERS", + "prompt": "Allowed sender emails (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Only emails from these addresses will be processed.", + }, ], }, { @@ -3527,17 +3862,37 @@ _PLATFORMS = [ " → Messaging → A MESSAGE COMES IN → Webhook → https://your-server:8080/webhooks/twilio", ], "vars": [ - {"name": "TWILIO_ACCOUNT_SID", "prompt": "Twilio Account SID", "password": False, - "help": "Found on the Twilio Console dashboard."}, - {"name": "TWILIO_AUTH_TOKEN", "prompt": "Twilio Auth Token", "password": True, - "help": "Found on the Twilio Console dashboard (click to reveal)."}, - {"name": "TWILIO_PHONE_NUMBER", "prompt": "Twilio phone number (E.164 format, e.g. +15551234567)", "password": False, - "help": "The Twilio phone number to send SMS from."}, - {"name": "SMS_ALLOWED_USERS", "prompt": "Allowed phone numbers (comma-separated, E.164 format)", "password": False, - "is_allowlist": True, - "help": "Only messages from these phone numbers will be processed."}, - {"name": "SMS_HOME_CHANNEL", "prompt": "Home channel phone number (for cron/notification delivery, or empty)", "password": False, - "help": "Phone number to deliver cron job results and notifications to."}, + { + "name": "TWILIO_ACCOUNT_SID", + "prompt": "Twilio Account SID", + "password": False, + "help": "Found on the Twilio Console dashboard.", + }, + { + "name": "TWILIO_AUTH_TOKEN", + "prompt": "Twilio Auth Token", + "password": True, + "help": "Found on the Twilio Console dashboard (click to reveal).", + }, + { + "name": "TWILIO_PHONE_NUMBER", + "prompt": "Twilio phone number (E.164 format, e.g. +15551234567)", + "password": False, + "help": "The Twilio phone number to send SMS from.", + }, + { + "name": "SMS_ALLOWED_USERS", + "prompt": "Allowed phone numbers (comma-separated, E.164 format)", + "password": False, + "is_allowlist": True, + "help": "Only messages from these phone numbers will be processed.", + }, + { + "name": "SMS_HOME_CHANNEL", + "prompt": "Home channel phone number (for cron/notification delivery, or empty)", + "password": False, + "help": "Phone number to deliver cron job results and notifications to.", + }, ], }, { @@ -3552,10 +3907,18 @@ _PLATFORMS = [ "4. Add the bot to a group chat or message it directly", ], "vars": [ - {"name": "DINGTALK_CLIENT_ID", "prompt": "AppKey (Client ID)", "password": False, - "help": "The AppKey from your DingTalk application credentials."}, - {"name": "DINGTALK_CLIENT_SECRET", "prompt": "AppSecret (Client Secret)", "password": True, - "help": "The AppSecret from your DingTalk application credentials."}, + { + "name": "DINGTALK_CLIENT_ID", + "prompt": "AppKey (Client ID)", + "password": False, + "help": "The AppKey from your DingTalk application credentials.", + }, + { + "name": "DINGTALK_CLIENT_SECRET", + "prompt": "AppSecret (Client Secret)", + "password": True, + "help": "The AppSecret from your DingTalk application credentials.", + }, ], }, { @@ -3572,19 +3935,43 @@ _PLATFORMS = [ "6. Restrict access with FEISHU_ALLOWED_USERS for production use", ], "vars": [ - {"name": "FEISHU_APP_ID", "prompt": "App ID", "password": False, - "help": "The App ID from your Feishu/Lark application."}, - {"name": "FEISHU_APP_SECRET", "prompt": "App Secret", "password": True, - "help": "The App Secret from your Feishu/Lark application."}, - {"name": "FEISHU_DOMAIN", "prompt": "Domain — feishu or lark (default: feishu)", "password": False, - "help": "Use 'feishu' for Feishu China, or 'lark' for Lark international."}, - {"name": "FEISHU_CONNECTION_MODE", "prompt": "Connection mode — websocket or webhook (default: websocket)", "password": False, - "help": "websocket is recommended unless you specifically need webhook mode."}, - {"name": "FEISHU_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False, - "is_allowlist": True, - "help": "Restrict which Feishu/Lark users can interact with the bot."}, - {"name": "FEISHU_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False, - "help": "Chat ID for scheduled results and notifications."}, + { + "name": "FEISHU_APP_ID", + "prompt": "App ID", + "password": False, + "help": "The App ID from your Feishu/Lark application.", + }, + { + "name": "FEISHU_APP_SECRET", + "prompt": "App Secret", + "password": True, + "help": "The App Secret from your Feishu/Lark application.", + }, + { + "name": "FEISHU_DOMAIN", + "prompt": "Domain — feishu or lark (default: feishu)", + "password": False, + "help": "Use 'feishu' for Feishu China, or 'lark' for Lark international.", + }, + { + "name": "FEISHU_CONNECTION_MODE", + "prompt": "Connection mode — websocket or webhook (default: websocket)", + "password": False, + "help": "websocket is recommended unless you specifically need webhook mode.", + }, + { + "name": "FEISHU_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, or empty)", + "password": False, + "is_allowlist": True, + "help": "Restrict which Feishu/Lark users can interact with the bot.", + }, + { + "name": "FEISHU_HOME_CHANNEL", + "prompt": "Home chat ID (optional, for cron/notifications)", + "password": False, + "help": "Chat ID for scheduled results and notifications.", + }, ], }, { @@ -3600,15 +3987,31 @@ _PLATFORMS = [ "5. Restrict access with WECOM_ALLOWED_USERS for production use", ], "vars": [ - {"name": "WECOM_BOT_ID", "prompt": "Bot ID", "password": False, - "help": "The Bot ID from your WeCom AI Bot."}, - {"name": "WECOM_SECRET", "prompt": "Secret", "password": True, - "help": "The secret from your WeCom AI Bot."}, - {"name": "WECOM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False, - "is_allowlist": True, - "help": "Restrict which WeCom users can interact with the bot."}, - {"name": "WECOM_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False, - "help": "Chat ID for scheduled results and notifications."}, + { + "name": "WECOM_BOT_ID", + "prompt": "Bot ID", + "password": False, + "help": "The Bot ID from your WeCom AI Bot.", + }, + { + "name": "WECOM_SECRET", + "prompt": "Secret", + "password": True, + "help": "The secret from your WeCom AI Bot.", + }, + { + "name": "WECOM_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, or empty)", + "password": False, + "is_allowlist": True, + "help": "Restrict which WeCom users can interact with the bot.", + }, + { + "name": "WECOM_HOME_CHANNEL", + "prompt": "Home chat ID (optional, for cron/notifications)", + "password": False, + "help": "Chat ID for scheduled results and notifications.", + }, ], }, { @@ -3625,21 +4028,49 @@ _PLATFORMS = [ "6. Restrict access with WECOM_CALLBACK_ALLOWED_USERS for production use", ], "vars": [ - {"name": "WECOM_CALLBACK_CORP_ID", "prompt": "Corp ID", "password": False, - "help": "Your WeCom enterprise Corp ID."}, - {"name": "WECOM_CALLBACK_CORP_SECRET", "prompt": "Corp Secret", "password": True, - "help": "The secret for your self-built application."}, - {"name": "WECOM_CALLBACK_AGENT_ID", "prompt": "Agent ID", "password": False, - "help": "The Agent ID of your self-built application."}, - {"name": "WECOM_CALLBACK_TOKEN", "prompt": "Callback Token", "password": True, - "help": "The Token from your WeCom callback configuration."}, - {"name": "WECOM_CALLBACK_ENCODING_AES_KEY", "prompt": "Encoding AES Key", "password": True, - "help": "The EncodingAESKey from your WeCom callback configuration."}, - {"name": "WECOM_CALLBACK_PORT", "prompt": "Callback server port (default: 8645)", "password": False, - "help": "Port for the HTTP callback server."}, - {"name": "WECOM_CALLBACK_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False, - "is_allowlist": True, - "help": "Restrict which WeCom users can interact with the app."}, + { + "name": "WECOM_CALLBACK_CORP_ID", + "prompt": "Corp ID", + "password": False, + "help": "Your WeCom enterprise Corp ID.", + }, + { + "name": "WECOM_CALLBACK_CORP_SECRET", + "prompt": "Corp Secret", + "password": True, + "help": "The secret for your self-built application.", + }, + { + "name": "WECOM_CALLBACK_AGENT_ID", + "prompt": "Agent ID", + "password": False, + "help": "The Agent ID of your self-built application.", + }, + { + "name": "WECOM_CALLBACK_TOKEN", + "prompt": "Callback Token", + "password": True, + "help": "The Token from your WeCom callback configuration.", + }, + { + "name": "WECOM_CALLBACK_ENCODING_AES_KEY", + "prompt": "Encoding AES Key", + "password": True, + "help": "The EncodingAESKey from your WeCom callback configuration.", + }, + { + "name": "WECOM_CALLBACK_PORT", + "prompt": "Callback server port (default: 8645)", + "password": False, + "help": "Port for the HTTP callback server.", + }, + { + "name": "WECOM_CALLBACK_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, or empty)", + "password": False, + "is_allowlist": True, + "help": "Restrict which WeCom users can interact with the app.", + }, ], }, { @@ -3665,15 +4096,31 @@ _PLATFORMS = [ " Share the code — the user sends it via iMessage to get approved", ], "vars": [ - {"name": "BLUEBUBBLES_SERVER_URL", "prompt": "BlueBubbles server URL (e.g. http://192.168.1.10:1234)", "password": False, - "help": "The URL shown in BlueBubbles Settings → API."}, - {"name": "BLUEBUBBLES_PASSWORD", "prompt": "BlueBubbles server password", "password": True, - "help": "The password shown in BlueBubbles Settings → API."}, - {"name": "BLUEBUBBLES_ALLOWED_USERS", "prompt": "Pre-authorized phone numbers or iMessage IDs (comma-separated, or leave empty for DM pairing)", "password": False, - "is_allowlist": True, - "help": "Optional — pre-authorize specific users. Leave empty to use DM pairing instead (recommended)."}, - {"name": "BLUEBUBBLES_HOME_CHANNEL", "prompt": "Home channel (phone number or iMessage ID for cron/notifications, or empty)", "password": False, - "help": "Phone number or Apple ID to deliver cron results and notifications to."}, + { + "name": "BLUEBUBBLES_SERVER_URL", + "prompt": "BlueBubbles server URL (e.g. http://192.168.1.10:1234)", + "password": False, + "help": "The URL shown in BlueBubbles Settings → API.", + }, + { + "name": "BLUEBUBBLES_PASSWORD", + "prompt": "BlueBubbles server password", + "password": True, + "help": "The password shown in BlueBubbles Settings → API.", + }, + { + "name": "BLUEBUBBLES_ALLOWED_USERS", + "prompt": "Pre-authorized phone numbers or iMessage IDs (comma-separated, or leave empty for DM pairing)", + "password": False, + "is_allowlist": True, + "help": "Optional — pre-authorize specific users. Leave empty to use DM pairing instead (recommended).", + }, + { + "name": "BLUEBUBBLES_HOME_CHANNEL", + "prompt": "Home channel (phone number or iMessage ID for cron/notifications, or empty)", + "password": False, + "help": "Phone number or Apple ID to deliver cron results and notifications to.", + }, ], }, { @@ -3688,15 +4135,31 @@ _PLATFORMS = [ "4. Configure sandbox or publish the bot", ], "vars": [ - {"name": "QQ_APP_ID", "prompt": "QQ Bot App ID", "password": False, - "help": "Your QQ Bot App ID from q.qq.com."}, - {"name": "QQ_CLIENT_SECRET", "prompt": "QQ Bot App Secret", "password": True, - "help": "Your QQ Bot App Secret from q.qq.com."}, - {"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False, - "is_allowlist": True, - "help": "Optional — restrict DM access to specific user OpenIDs."}, - {"name": "QQBOT_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False, - "help": "OpenID to deliver cron results and notifications to."}, + { + "name": "QQ_APP_ID", + "prompt": "QQ Bot App ID", + "password": False, + "help": "Your QQ Bot App ID from q.qq.com.", + }, + { + "name": "QQ_CLIENT_SECRET", + "prompt": "QQ Bot App Secret", + "password": True, + "help": "Your QQ Bot App Secret from q.qq.com.", + }, + { + "name": "QQ_ALLOWED_USERS", + "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", + "password": False, + "is_allowlist": True, + "help": "Optional — restrict DM access to specific user OpenIDs.", + }, + { + "name": "QQBOT_HOME_CHANNEL", + "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", + "password": False, + "help": "OpenID to deliver cron results and notifications to.", + }, ], }, { @@ -3711,13 +4174,23 @@ _PLATFORMS = [ "4. Enter them below and Hermes will connect automatically over WebSocket", ], "vars": [ - {"name": "YUANBAO_APP_ID", "prompt": "App ID", "password": False, - "help": "The App ID from your Yuanbao IM Bot credentials."}, - {"name": "YUANBAO_APP_SECRET", "prompt": "App Secret", "password": True, - "help": "The App Secret (used for HMAC signing) from your Yuanbao IM Bot."}, + { + "name": "YUANBAO_APP_ID", + "prompt": "App ID", + "password": False, + "help": "The App ID from your Yuanbao IM Bot credentials.", + }, + { + "name": "YUANBAO_APP_SECRET", + "prompt": "App Secret", + "password": True, + "help": "The App Secret (used for HMAC signing) from your Yuanbao IM Bot.", + }, ], }, ] + + def _all_platforms() -> list[dict]: """Return the full list of platforms for setup menus. @@ -3744,6 +4217,7 @@ def _all_platforms() -> list[dict]: # opt-in via ``plugins.enabled`` (untrusted code). try: from hermes_cli.plugins import discover_plugins + discover_plugins() except Exception as e: logger.debug("plugin discovery failed during platform enumeration: %s", e) @@ -3764,14 +4238,16 @@ def _all_platforms() -> list[dict]: for entry in platform_registry.all_entries(): if entry.name in by_key: continue # built-in already covers it - platforms.append({ - "key": entry.name, - "label": entry.label, - "emoji": entry.emoji, - "token_var": entry.required_env[0] if entry.required_env else "", - "install_hint": entry.install_hint, - "_registry_entry": entry, - }) + platforms.append( + { + "key": entry.name, + "label": entry.label, + "emoji": entry.emoji, + "token_var": entry.required_env[0] if entry.required_env else "", + "install_hint": entry.install_hint, + "_registry_entry": entry, + } + ) return platforms @@ -3789,6 +4265,7 @@ def _platform_status(platform: dict) -> str: if entry.is_connected is not None: try: from gateway.config import PlatformConfig + synthetic = PlatformConfig(enabled=True) configured = bool(entry.is_connected(synthetic)) except Exception: @@ -3952,15 +4429,23 @@ def _setup_standard_platform(platform: dict): "Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", "Skip for now (bot will deny all users until configured)", ] - access_idx = prompt_choice(" How should unauthorized users be handled?", access_choices, 1) + access_idx = prompt_choice( + " How should unauthorized users be handled?", access_choices, 1 + ) if access_idx == 0: save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") print_warning(" Open access enabled — anyone can use your bot!") elif access_idx == 1: - print_success(" DM pairing mode — users will receive a code to request access.") - print_info(" Approve with: hermes pairing approve <platform> <code>") + print_success( + " DM pairing mode — users will receive a code to request access." + ) + print_info( + " Approve with: hermes pairing approve <platform> <code>" + ) else: - print_info(" Skipped — configure later with 'hermes gateway setup'") + print_info( + " Skipped — configure later with 'hermes gateway setup'" + ) continue value = prompt(f" {var['prompt']}", password=var.get("password", False)) @@ -3979,7 +4464,9 @@ def _setup_standard_platform(platform: dict): home_val = get_env_value(home_var) if allowed_val_set and not home_val and label == "Telegram": first_id = allowed_val_set.split(",")[0].strip() - if first_id and prompt_yes_no(f" Use your user ID ({first_id}) as the home channel?", True): + if first_id and prompt_yes_no( + f" Use your user ID ({first_id}) as the home channel?", True + ): save_env_value(home_var, first_id) print_success(f" Home channel set to {first_id}") @@ -3991,13 +4478,17 @@ def _setup_whatsapp(): """Delegate to the existing WhatsApp setup flow.""" from hermes_cli.main import cmd_whatsapp import argparse + cmd_whatsapp(argparse.Namespace()) def _setup_dingtalk(): """Configure DingTalk — QR scan (recommended) or manual credential entry.""" from hermes_cli.setup import ( - prompt_choice, prompt_yes_no, print_success, print_warning, + prompt_choice, + prompt_yes_no, + print_success, + print_warning, ) dingtalk_platform = next(p for p in _PLATFORMS if p["key"] == "dingtalk") @@ -4029,7 +4520,9 @@ def _setup_dingtalk(): try: from hermes_cli.dingtalk_auth import dingtalk_qr_auth except ImportError as exc: - print_warning(f" QR auth module failed to load ({exc}), falling back to manual input.") + print_warning( + f" QR auth module failed to load ({exc}), falling back to manual input." + ) _setup_standard_platform(dingtalk_platform) return @@ -4068,7 +4561,9 @@ def _setup_wecom(): "Scan QR code to obtain Bot ID and Secret automatically (recommended)", "Enter existing Bot ID and Secret manually", ] - method_idx = prompt_choice(" How would you like to set up WeCom?", method_choices, 0) + method_idx = prompt_choice( + " How would you like to set up WeCom?", method_choices, 0 + ) bot_id = None secret = None @@ -4104,7 +4599,9 @@ def _setup_wecom(): # ── Manual credential input ── if not bot_id or not secret: print() - print_info(" 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots") + print_info( + " 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots" + ) print_info(" 2. Select API Mode") print_info(" 3. Copy the Bot ID and Secret from the bot's credentials info") print_info(" 4. The bot connects via WebSocket — no public endpoint needed") @@ -4139,14 +4636,18 @@ def _setup_wecom(): "Disable direct messages", "Skip for now (bot will deny all users until configured)", ] - access_idx = prompt_choice(" How should unauthorized users be handled?", access_choices, 1) + access_idx = prompt_choice( + " How should unauthorized users be handled?", access_choices, 1 + ) if access_idx == 0: save_env_value("WECOM_DM_POLICY", "open") save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") print_warning(" Open access enabled — anyone can use your bot!") elif access_idx == 1: save_env_value("WECOM_DM_POLICY", "pairing") - print_success(" DM pairing mode — users will receive a code to request access.") + print_success( + " DM pairing mode — users will receive a code to request access." + ) print_info(" Approve with: hermes pairing approve <platform> <code>") elif access_idx == 2: save_env_value("WECOM_DM_POLICY", "disabled") @@ -4169,11 +4670,15 @@ def _setup_wecom(): def _is_service_installed() -> bool: """Check if the gateway is installed as a system service.""" if supports_systemd_services(): - return get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists() + return ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ) elif is_macos(): return get_launchd_plist_path().exists() elif is_windows(): from hermes_cli import gateway_windows + return gateway_windows.is_installed() return False @@ -4188,7 +4693,10 @@ def _is_service_running() -> bool: try: result = _run_systemctl( ["is-active", get_service_name()], - system=False, capture_output=True, text=True, timeout=10, + system=False, + capture_output=True, + text=True, + timeout=10, ) if result.stdout.strip() == "active": return True @@ -4199,7 +4707,10 @@ def _is_service_running() -> bool: try: result = _run_systemctl( ["is-active", get_service_name()], - system=True, capture_output=True, text=True, timeout=10, + system=True, + capture_output=True, + text=True, + timeout=10, ) if result.stdout.strip() == "active": return True @@ -4211,13 +4722,16 @@ def _is_service_running() -> bool: try: result = subprocess.run( ["launchctl", "list", get_launchd_label()], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) return result.returncode == 0 except subprocess.TimeoutExpired: return False elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): # "installed" doesn't necessarily mean "running" on Windows. The # canonical check is whether a gateway process actually exists. @@ -4233,8 +4747,12 @@ def _setup_weixin(): print() print_info(" 1. Hermes will open Tencent iLink QR login in this terminal.") print_info(" 2. Use WeChat to scan and confirm the QR code.") - print_info(" 3. Hermes will store the returned account_id/token in ~/.hermes/.env.") - print_info(" 4. This adapter supports native text, image, video, and document delivery.") + print_info( + " 3. Hermes will store the returned account_id/token in ~/.hermes/.env." + ) + print_info( + " 4. This adapter supports native text, image, video, and document delivery." + ) existing_account = get_env_value("WEIXIN_ACCOUNT_ID") existing_token = get_env_value("WEIXIN_TOKEN") @@ -4262,6 +4780,7 @@ def _setup_weixin(): return import asyncio + try: credentials = asyncio.run(qr_login(str(get_hermes_home()))) except KeyboardInterrupt: @@ -4285,7 +4804,10 @@ def _setup_weixin(): save_env_value("WEIXIN_TOKEN", token) if base_url: save_env_value("WEIXIN_BASE_URL", base_url) - save_env_value("WEIXIN_CDN_BASE_URL", get_env_value("WEIXIN_CDN_BASE_URL") or "https://novac2c.cdn.weixin.qq.com/c2c") + save_env_value( + "WEIXIN_CDN_BASE_URL", + get_env_value("WEIXIN_CDN_BASE_URL") or "https://novac2c.cdn.weixin.qq.com/c2c", + ) print() access_choices = [ @@ -4294,13 +4816,17 @@ def _setup_weixin(): "Only allow listed user IDs", "Disable direct messages", ] - access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + access_idx = prompt_choice( + " How should direct messages be authorized?", access_choices, 0 + ) if access_idx == 0: save_env_value("WEIXIN_DM_POLICY", "pairing") save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") save_env_value("WEIXIN_ALLOWED_USERS", "") print_success(" DM pairing enabled.") - print_info(" Unknown DM users can request access and you approve them with `hermes pairing approve`.") + print_info( + " Unknown DM users can request access and you approve them with `hermes pairing approve`." + ) elif access_idx == 1: save_env_value("WEIXIN_DM_POLICY", "open") save_env_value("WEIXIN_ALLOW_ALL_USERS", "true") @@ -4308,7 +4834,9 @@ def _setup_weixin(): print_warning(" Open DM access enabled for Weixin.") elif access_idx == 2: default_allow = user_id or "" - allowlist = prompt(" Allowed Weixin user IDs (comma-separated)", default_allow, password=False).replace(" ", "") + allowlist = prompt( + " Allowed Weixin user IDs (comma-separated)", default_allow, password=False + ).replace(" ", "") save_env_value("WEIXIN_DM_POLICY", "allowlist") save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") save_env_value("WEIXIN_ALLOWED_USERS", allowlist) @@ -4320,11 +4848,21 @@ def _setup_weixin(): print_warning(" Direct messages disabled.") print() - print_info(" Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a") - print_info(" scriptable personal WeChat account. Ordinary WeChat groups typically cannot") - print_info(" invite an @im.bot identity, and iLink does not deliver ordinary-group events") - print_info(" to most bot accounts. The settings below only apply when iLink actually") - print_info(" delivers group events for your account type — otherwise DM remains the only") + print_info( + " Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a" + ) + print_info( + " scriptable personal WeChat account. Ordinary WeChat groups typically cannot" + ) + print_info( + " invite an @im.bot identity, and iLink does not deliver ordinary-group events" + ) + print_info( + " to most bot accounts. The settings below only apply when iLink actually" + ) + print_info( + " delivers group events for your account type — otherwise DM remains the only" + ) print_info(" working channel regardless of this choice.") group_choices = [ "Disable group chats (recommended)", @@ -4339,16 +4877,26 @@ def _setup_weixin(): elif group_idx == 1: save_env_value("WEIXIN_GROUP_POLICY", "open") save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "") - print_warning(" All group chats enabled (only takes effect if iLink delivers group events).") + print_warning( + " All group chats enabled (only takes effect if iLink delivers group events)." + ) else: - allow_groups = prompt(" Allowed group chat IDs (comma-separated, not member user IDs)", "", password=False).replace(" ", "") + allow_groups = prompt( + " Allowed group chat IDs (comma-separated, not member user IDs)", + "", + password=False, + ).replace(" ", "") save_env_value("WEIXIN_GROUP_POLICY", "allowlist") save_env_value("WEIXIN_GROUP_ALLOWED_USERS", allow_groups) - print_success(" Group allowlist saved (only takes effect if iLink delivers group events).") + print_success( + " Group allowlist saved (only takes effect if iLink delivers group events)." + ) if user_id: print() - if prompt_yes_no(f" Use your Weixin user ID ({user_id}) as the home channel?", True): + if prompt_yes_no( + f" Use your Weixin user ID ({user_id}) as the home channel?", True + ): save_env_value("WEIXIN_HOME_CHANNEL", user_id) print_success(f" Home channel set to {user_id}") @@ -4378,7 +4926,9 @@ def _setup_feishu(): "Scan QR code to create a new bot automatically (recommended)", "Enter existing App ID and App Secret manually", ] - method_idx = prompt_choice(" How would you like to set up Feishu / Lark?", method_choices, 0) + method_idx = prompt_choice( + " How would you like to set up Feishu / Lark?", method_choices, 0 + ) credentials = None used_qr = False @@ -4408,8 +4958,12 @@ def _setup_feishu(): # ── Manual credential input ── if not credentials: print() - print_info(" Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)") - print_info(" Create an app, enable the Bot capability, and copy the credentials.") + print_info( + " Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)" + ) + print_info( + " Create an app, enable the Bot capability, and copy the credentials." + ) print() app_id = prompt(" App ID", password=False) if not app_id: @@ -4428,12 +4982,15 @@ def _setup_feishu(): bot_name = None try: from gateway.platforms.feishu import probe_bot + bot_info = probe_bot(app_id, app_secret, domain) if bot_info: bot_name = bot_info.get("bot_name") print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}") else: - print_warning(" Could not verify bot connection. Credentials saved anyway.") + print_warning( + " Could not verify bot connection. Credentials saved anyway." + ) except Exception as exc: print_warning(f" Credential verification skipped: {exc}") @@ -4470,8 +5027,12 @@ def _setup_feishu(): connection_mode = "webhook" if mode_idx == 1 else "websocket" if connection_mode == "webhook": print_info(" Webhook defaults: 127.0.0.1:8765/feishu/webhook") - print_info(" Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH") - print_info(" For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN") + print_info( + " Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH" + ) + print_info( + " For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN" + ) save_env_value("FEISHU_CONNECTION_MODE", connection_mode) if bot_name: @@ -4485,12 +5046,16 @@ def _setup_feishu(): "Allow all direct messages", "Only allow listed user IDs", ] - access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + access_idx = prompt_choice( + " How should direct messages be authorized?", access_choices, 0 + ) if access_idx == 0: save_env_value("FEISHU_ALLOW_ALL_USERS", "false") save_env_value("FEISHU_ALLOWED_USERS", "") print_success(" DM pairing enabled.") - print_info(" Unknown users can request access; approve with `hermes pairing approve`.") + print_info( + " Unknown users can request access; approve with `hermes pairing approve`." + ) elif access_idx == 1: save_env_value("FEISHU_ALLOW_ALL_USERS", "true") save_env_value("FEISHU_ALLOWED_USERS", "") @@ -4498,7 +5063,9 @@ def _setup_feishu(): else: save_env_value("FEISHU_ALLOW_ALL_USERS", "false") default_allow = open_id or "" - allowlist = prompt(" Allowed user IDs (comma-separated)", default_allow, password=False).replace(" ", "") + allowlist = prompt( + " Allowed user IDs (comma-separated)", default_allow, password=False + ).replace(" ", "") save_env_value("FEISHU_ALLOWED_USERS", allowlist) print_success(" Allowlist saved.") @@ -4518,7 +5085,9 @@ def _setup_feishu(): # ── Home channel ── print() - home_channel = prompt(" Home chat ID (optional, for cron/notifications)", password=False) + home_channel = prompt( + " Home chat ID (optional, for cron/notifications)", password=False + ) if home_channel: save_env_value("FEISHU_HOME_CHANNEL", home_channel) print_success(f" Home channel set to {home_channel}") @@ -4550,7 +5119,9 @@ def _setup_qqbot(): "Scan QR code to add bot automatically (recommended)", "Enter existing App ID and App Secret manually", ] - method_idx = prompt_choice(" How would you like to set up QQ Bot?", method_choices, 0) + method_idx = prompt_choice( + " How would you like to set up QQ Bot?", method_choices, 0 + ) credentials = None @@ -4558,6 +5129,7 @@ def _setup_qqbot(): # ── QR scan-to-configure ── try: from gateway.platforms.qqbot import qr_register + credentials = qr_register() except KeyboardInterrupt: print() @@ -4580,7 +5152,11 @@ def _setup_qqbot(): if not app_secret: print_warning(" Skipped — QQ Bot won't work without an App Secret.") return - credentials = {"app_id": app_id.strip(), "client_secret": app_secret.strip(), "user_openid": ""} + credentials = { + "app_id": app_id.strip(), + "client_secret": app_secret.strip(), + "user_openid": "", + } # ── Save core credentials ── save_env_value("QQ_APP_ID", credentials["app_id"]) @@ -4595,12 +5171,16 @@ def _setup_qqbot(): "Allow all direct messages", "Only allow listed user OpenIDs", ] - access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + access_idx = prompt_choice( + " How should direct messages be authorized?", access_choices, 0 + ) if access_idx == 0: save_env_value("QQ_ALLOW_ALL_USERS", "false") if user_openid: print() - if prompt_yes_no(f" Add yourself ({user_openid}) to the allow list?", True): + if prompt_yes_no( + f" Add yourself ({user_openid}) to the allow list?", True + ): save_env_value("QQ_ALLOWED_USERS", user_openid) print_success(f" Allow list set to {user_openid}") else: @@ -4608,14 +5188,18 @@ def _setup_qqbot(): else: save_env_value("QQ_ALLOWED_USERS", "") print_success(" DM pairing enabled.") - print_info(" Unknown users can request access; approve with `hermes pairing approve`.") + print_info( + " Unknown users can request access; approve with `hermes pairing approve`." + ) elif access_idx == 1: save_env_value("QQ_ALLOW_ALL_USERS", "true") save_env_value("QQ_ALLOWED_USERS", "") print_warning(" Open DM access enabled for QQ Bot.") else: default_allow = user_openid or "" - allowlist = prompt(" Allowed user OpenIDs (comma-separated)", default_allow, password=False).replace(" ", "") + allowlist = prompt( + " Allowed user OpenIDs (comma-separated)", default_allow, password=False + ).replace(" ", "") save_env_value("QQ_ALLOW_ALL_USERS", "false") save_env_value("QQ_ALLOWED_USERS", allowlist) print_success(" Allowlist saved.") @@ -4623,12 +5207,16 @@ def _setup_qqbot(): # ── Home channel ── if user_openid: print() - if prompt_yes_no(f" Use your QQ user ID ({user_openid}) as the home channel?", True): + if prompt_yes_no( + f" Use your QQ user ID ({user_openid}) as the home channel?", True + ): save_env_value("QQBOT_HOME_CHANNEL", user_openid) print_success(f" Home channel set to {user_openid}") else: print() - home_channel = prompt(" Home channel OpenID (for cron/notifications, or empty)", password=False) + home_channel = prompt( + " Home channel OpenID (for cron/notifications, or empty)", password=False + ) if home_channel: save_env_value("QQBOT_HOME_CHANNEL", home_channel.strip()) print_success(f" Home channel set to {home_channel.strip()}") @@ -4661,12 +5249,14 @@ def _setup_signal(): print_warning("signal-cli not found on PATH.") print_info(" Signal requires signal-cli running as an HTTP daemon.") print_info(" Install options:") - print_info(" Linux: download from https://github.com/AsamK/signal-cli/releases") + print_info( + " Linux: download from https://github.com/AsamK/signal-cli/releases" + ) print_info(" macOS: brew install signal-cli") print_info(" Docker: bbernhard/signal-cli-rest-api") print() print_info(" After installing, link your account and start the daemon:") - print_info(" signal-cli link -n \"HermesAgent\"") + print_info(' signal-cli link -n "HermesAgent"') print_info(" signal-cli --account +YOURNUMBER daemon --http 127.0.0.1:8080") print() @@ -4684,6 +5274,7 @@ def _setup_signal(): print_info(" Testing connection...") try: import httpx + resp = httpx.get(f"{url.rstrip('/')}/api/v1/check", timeout=10.0) if resp.status_code == 200: print_success(" signal-cli daemon is reachable!") @@ -4693,7 +5284,9 @@ def _setup_signal(): return except Exception as e: print_warning(f" Could not reach signal-cli at {url}: {e}") - if not prompt_yes_no(" Save this URL anyway? (you can start signal-cli later)", True): + if not prompt_yes_no( + " Save this URL anyway? (you can start signal-cli later)", True + ): return save_env_value("SIGNAL_HTTP_URL", url) @@ -4704,7 +5297,9 @@ def _setup_signal(): print_info(" Example: +15551234567") default_account = existing_account or "" try: - account = input(f" Account number{f' [{default_account}]' if default_account else ''}: ").strip() + account = input( + f" Account number{f' [{default_account}]' if default_account else ''}: " + ).strip() if not account: account = default_account except (EOFError, KeyboardInterrupt): @@ -4724,7 +5319,9 @@ def _setup_signal(): existing_allowed = get_env_value("SIGNAL_ALLOWED_USERS") or "" default_allowed = existing_allowed or account try: - allowed = input(f" Allowed users [{default_allowed}]: ").strip() or default_allowed + allowed = ( + input(f" Allowed users [{default_allowed}]: ").strip() or default_allowed + ) except (EOFError, KeyboardInterrupt): print("\n Setup cancelled.") return @@ -4733,12 +5330,18 @@ def _setup_signal(): # Group messaging print() - if prompt_yes_no(" Enable group messaging? (disabled by default for security)", False): + if prompt_yes_no( + " Enable group messaging? (disabled by default for security)", False + ): print() print_info(" Enter group IDs to allow, or * for all groups.") existing_groups = get_env_value("SIGNAL_GROUP_ALLOWED_USERS") or "" try: - groups = input(f" Group IDs [{existing_groups or '*'}]: ").strip() or existing_groups or "*" + groups = ( + input(f" Group IDs [{existing_groups or '*'}]: ").strip() + or existing_groups + or "*" + ) except (EOFError, KeyboardInterrupt): print("\n Setup cancelled.") return @@ -4749,7 +5352,9 @@ def _setup_signal(): print_info(f" URL: {url}") print_info(f" Account: {account}") print_info(" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing") - print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}") + print_info( + f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}" + ) def _builtin_setup_fn(key: str): @@ -4759,6 +5364,7 @@ def _builtin_setup_fn(key: str): imports from this module for the remaining bespoke flows). """ from hermes_cli import setup as _s + return { "telegram": _s._setup_telegram, # discord moved into the plugin: setup_fn is registered by @@ -4779,6 +5385,8 @@ def _builtin_setup_fn(key: str): "wecom": _setup_wecom, "qqbot": _setup_qqbot, }.get(key) + + def _configure_platform(platform: dict) -> None: """Run the interactive setup flow for a single platform. @@ -4816,7 +5424,9 @@ def _configure_platform(platform: dict) -> None: if required: print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}") else: - print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}") + print_info( + f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}" + ) if platform.get("install_hint"): print_info(f" {platform['install_hint']}") @@ -4828,12 +5438,40 @@ def gateway_setup(): return print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) - print(color("│ ⚕ Gateway Setup │", Colors.MAGENTA)) - print(color("├─────────────────────────────────────────────────────────┤", Colors.MAGENTA)) - print(color("│ Configure messaging platforms and the gateway service. │", Colors.MAGENTA)) - print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) + print( + color( + "│ ⚕ Gateway Setup │", Colors.MAGENTA + ) + ) + print( + color( + "├─────────────────────────────────────────────────────────┤", + Colors.MAGENTA, + ) + ) + print( + color( + "│ Configure messaging platforms and the gateway service. │", + Colors.MAGENTA, + ) + ) + print( + color( + "│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA + ) + ) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) # ── Gateway service status ── print() @@ -4884,12 +5522,13 @@ def gateway_setup(): platforms = _all_platforms() menu_items = [ - f"{p['emoji']} {p['label']} ({_platform_status(p)})" - for p in platforms + f"{p['emoji']} {p['label']} ({_platform_status(p)})" for p in platforms ] menu_items.append("Done") - choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1) + choice = prompt_choice( + "Select a platform to configure:", menu_items, len(menu_items) - 1 + ) if choice == len(platforms): break @@ -4908,9 +5547,7 @@ def gateway_setup(): or s.startswith("plugin disabled") ) - any_configured = any( - _is_progress(_platform_status(p)) for p in _all_platforms() - ) + any_configured = any(_is_progress(_platform_status(p)) for p in _all_platforms()) if any_configured: print() @@ -4929,6 +5566,7 @@ def gateway_setup(): launchd_restart() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.restart() else: stop_profile_gateway() @@ -4953,6 +5591,7 @@ def gateway_setup(): launchd_start() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.start() except UserSystemdUnavailableError as e: print_error(" Start failed — user systemd not reachable:") @@ -4992,6 +5631,7 @@ def gateway_setup(): did_install = True else: from hermes_cli import gateway_windows + gateway_windows.install(force=False) did_install = True print() @@ -5005,7 +5645,9 @@ def gateway_setup(): from hermes_cli import gateway_windows gateway_windows.start() except UserSystemdUnavailableError as e: - print_error(" Start failed — user systemd not reachable:") + print_error( + " Start failed — user systemd not reachable:" + ) for line in str(e).splitlines(): print(f" {line}") except subprocess.CalledProcessError as e: @@ -5017,18 +5659,27 @@ def gateway_setup(): print_info(" Skipped start and auto-start setup.") print_info(" You can install later: hermes gateway install") if supports_systemd_services(): - print_info(" Or as a boot-time service: sudo hermes gateway install --system") + print_info( + " Or as a boot-time service: sudo hermes gateway install --system" + ) print_info(" Or run in foreground: hermes gateway run") elif is_wsl(): print_info(" WSL detected but systemd is not running.") print_info(" Run in foreground: hermes gateway run") - print_info(" For persistence: tmux new -s hermes 'hermes gateway run'") - print_info(" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'") + print_info( + " For persistence: tmux new -s hermes 'hermes gateway run'" + ) + print_info( + " To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'" + ) elif is_termux(): from hermes_constants import display_hermes_home as _dhh + print_info(" Termux does not use systemd/launchd services.") print_info(" Run in foreground: hermes gateway run") - print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &") + print_info( + f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &" + ) else: print_info(" Service install not supported on this platform.") print_info(" Run in foreground: hermes gateway run") @@ -5145,6 +5796,7 @@ def _dispatch_all_via_service_manager_if_s6(action: str) -> bool: return True + def gateway_command(args): """Handle gateway subcommands.""" try: @@ -5237,15 +5889,15 @@ def _maybe_redirect_run_to_s6_supervision(args) -> bool: def _gateway_command_inner(args): - subcmd = getattr(args, 'gateway_command', None) - + subcmd = getattr(args, "gateway_command", None) + # Default to run if no subcommand if subcmd is None or subcmd == "run": if _maybe_redirect_run_to_s6_supervision(args): return # unreachable; execvp doesn't return - verbose = getattr(args, 'verbose', 0) - quiet = getattr(args, 'quiet', False) - replace = getattr(args, 'replace', False) + verbose = getattr(args, "verbose", 0) + quiet = getattr(args, "quiet", False) + replace = getattr(args, "replace", False) run_gateway(verbose, quiet=quiet, replace=replace) return @@ -5258,18 +5910,24 @@ def _gateway_command_inner(args): if is_managed(): managed_error("install gateway service (managed by NixOS)") return - force = getattr(args, 'force', False) - system = getattr(args, 'system', False) - run_as_user = getattr(args, 'run_as_user', None) + force = getattr(args, "force", False) + system = getattr(args, "system", False) + run_as_user = getattr(args, "run_as_user", None) if is_termux(): print("Gateway service installation is not supported on Termux.") print("Run manually: hermes gateway") sys.exit(1) if supports_systemd_services(): if is_wsl(): - print_warning("WSL detected — systemd services may not survive WSL restarts.") - print_info(" Consider running in foreground instead: hermes gateway run") - print_info(" Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'") + print_warning( + "WSL detected — systemd services may not survive WSL restarts." + ) + print_info( + " Consider running in foreground instead: hermes gateway run" + ) + print_info( + " Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'" + ) print() start_now = prompt_yes_no("Start the gateway now after installing the service?", True) start_on_login = prompt_yes_no("Start the gateway automatically on login/boot with systemd?", True) @@ -5285,6 +5943,7 @@ def _gateway_command_inner(args): launchd_install(force) elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.install( force=force, start_now=getattr(args, 'start_now', None), @@ -5293,12 +5952,20 @@ def _gateway_command_inner(args): ) elif is_wsl(): print("WSL detected but systemd is not running.") - print("Either enable systemd (add systemd=true to /etc/wsl.conf and restart WSL)") + print( + "Either enable systemd (add systemd=true to /etc/wsl.conf and restart WSL)" + ) print("or run the gateway in foreground mode:") print() - print(" hermes gateway run # direct foreground") - print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") + print( + " hermes gateway run # direct foreground" + ) + print( + " tmux new -s hermes 'hermes gateway run' # persistent via tmux" + ) + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background" + ) sys.exit(1) elif is_container(): # Phase 4: inside a container with s6 the gateway service is @@ -5317,9 +5984,13 @@ def _gateway_command_inner(args): # /init, k8s plain runs, etc.) — the historical guidance still # applies. print("Service installation is not needed inside a Docker container.") - print("The container runtime is your service manager — use Docker restart policies instead:") + print( + "The container runtime is your service manager — use Docker restart policies instead:" + ) print() - print(" docker run --restart unless-stopped ... # auto-restart on crash/reboot") + print( + " docker run --restart unless-stopped ... # auto-restart on crash/reboot" + ) print(" docker restart <container> # manual restart") print() print("To run the gateway: hermes gateway run") @@ -5328,14 +5999,16 @@ def _gateway_command_inner(args): print("Service installation not supported on this platform.") print("Run manually: hermes gateway run") sys.exit(1) - + elif subcmd == "uninstall": if is_managed(): managed_error("uninstall gateway service (managed by NixOS)") return - system = getattr(args, 'system', False) + system = getattr(args, "system", False) if is_termux(): - print("Gateway service uninstall is not supported on Termux because there is no managed service to remove.") + print( + "Gateway service uninstall is not supported on Termux because there is no managed service to remove." + ) print("Stop manual runs with: hermes gateway stop") sys.exit(1) if supports_systemd_services(): @@ -5344,6 +6017,7 @@ def _gateway_command_inner(args): launchd_uninstall() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.uninstall() elif is_container(): from hermes_cli.service_manager import detect_service_manager @@ -5364,8 +6038,8 @@ def _gateway_command_inner(args): sys.exit(1) elif subcmd == "start": - system = getattr(args, 'system', False) - start_all = getattr(args, 'all', False) + system = getattr(args, "system", False) + start_all = getattr(args, "all", False) # Phase 4: inside a container with s6, dispatch via the service # manager instead of falling through to systemd/launchd/windows. @@ -5379,11 +6053,15 @@ def _gateway_command_inner(args): # Kill all stale gateway processes across all profiles before starting killed = kill_gateway_processes(all_profiles=True) if killed: - print(f"✓ Killed {killed} stale gateway process(es) across all profiles") + print( + f"✓ Killed {killed} stale gateway process(es) across all profiles" + ) _wait_for_gateway_exit(timeout=10.0, force_after=5.0) if is_termux(): - print("Gateway service start is not supported on Termux because there is no system service manager.") + print( + "Gateway service start is not supported on Termux because there is no system service manager." + ) print("Run manually: hermes gateway") sys.exit(1) if supports_systemd_services(): @@ -5392,16 +6070,25 @@ def _gateway_command_inner(args): launchd_start() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.start() elif is_wsl(): print("WSL detected but systemd is not available.") print("Run the gateway in foreground mode instead:") print() - print(" hermes gateway run # direct foreground") - print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") + print( + " hermes gateway run # direct foreground" + ) + print( + " tmux new -s hermes 'hermes gateway run' # persistent via tmux" + ) + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background" + ) print() - print("To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.") + print( + "To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell." + ) sys.exit(1) elif is_container(): # Reached only when s6 ISN'T running (the early dispatch @@ -5432,8 +6119,8 @@ def _gateway_command_inner(args): ) sys.exit(1) - stop_all = getattr(args, 'all', False) - system = getattr(args, 'system', False) + stop_all = getattr(args, "all", False) + system = getattr(args, "system", False) # Phase 4: inside a container with s6, dispatch via the service # manager. ``--all`` iterates every registered profile gateway @@ -5447,7 +6134,10 @@ def _gateway_command_inner(args): if stop_all: # --all: kill every gateway process on the machine service_available = False - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): try: systemd_stop(system=system) service_available = True @@ -5461,6 +6151,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): try: gateway_windows.stop() @@ -5476,7 +6167,10 @@ def _gateway_command_inner(args): else: # Default: stop only the current profile's gateway service_available = False - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): try: systemd_stop(system=system) service_available = True @@ -5490,6 +6184,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): try: gateway_windows.stop() @@ -5505,7 +6200,7 @@ def _gateway_command_inner(args): print("✗ No gateway running for this profile") else: print(f"✓ Stopped {get_service_name()} service") - + elif subcmd == "restart": # Defense: refuse self-targeting gateway restart from inside the gateway. # Prevents agent-initiated kill loops when combined with supervisor KeepAlive. @@ -5519,8 +6214,8 @@ def _gateway_command_inner(args): # Try service first, fall back to killing and restarting service_available = False - system = getattr(args, 'system', False) - restart_all = getattr(args, 'all', False) + system = getattr(args, "system", False) + restart_all = getattr(args, "all", False) service_configured = False # Phase 4: inside a container with s6, dispatch via the service @@ -5536,7 +6231,10 @@ def _gateway_command_inner(args): if restart_all: # --all: stop every gateway process across all profiles, then start fresh service_stopped = False - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): try: systemd_stop(system=system) service_stopped = True @@ -5550,6 +6248,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): try: gateway_windows.stop() @@ -5564,12 +6263,16 @@ def _gateway_command_inner(args): # Start the current profile's service fresh print("Starting gateway...") - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): systemd_start(system=system) elif is_macos() and get_launchd_plist_path().exists(): launchd_start() elif is_windows(): from hermes_cli import gateway_windows + # On Windows, even without a registered Scheduled Task / Startup # entry, gateway_windows.start() uses the safe detached # pythonw.exe launcher. Do not fall back to run_gateway() here: @@ -5580,8 +6283,11 @@ def _gateway_command_inner(args): else: run_gateway(verbose=0) return - - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): service_configured = True try: systemd_restart(system=system) @@ -5597,6 +6303,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + # Prefer the Windows-specific restart path: it supports both # registered Scheduled Task / Startup installs and no-service # detached restarts. In the normal successful Telegram-triggered @@ -5610,17 +6317,22 @@ def _gateway_command_inner(args): return except (subprocess.CalledProcessError, RuntimeError, OSError): pass - + if not service_available: # systemd/launchd restart failed — check if linger is the issue if supports_systemd_services(): linger_ok, _detail = get_systemd_linger_status() if linger_ok is not True: import getpass + _username = getpass.getuser() print() - print("⚠ Cannot restart gateway as a service — linger is not enabled.") - print(" The gateway user service requires linger to function on headless servers.") + print( + "⚠ Cannot restart gateway as a service — linger is not enabled." + ) + print( + " The gateway user service requires linger to function on headless servers." + ) print() print(f" Run: sudo loginctl enable-linger {_username}") print() @@ -5631,7 +6343,9 @@ def _gateway_command_inner(args): if service_configured: print() print("✗ Gateway service restart failed.") - print(" The service definition exists, but the service manager did not recover it.") + print( + " The service definition exists, but the service manager did not recover it." + ) print(" Fix the service, then retry: hermes gateway start") sys.exit(1) @@ -5644,19 +6358,23 @@ def _gateway_command_inner(args): # Start fresh print("Starting gateway...") run_gateway(verbose=0) - + elif subcmd == "status": - deep = getattr(args, 'deep', False) - full = getattr(args, 'full', False) - system = getattr(args, 'system', False) + deep = getattr(args, "deep", False) + full = getattr(args, "full", False) + system = getattr(args, "system", False) snapshot = get_gateway_runtime_snapshot(system=system) - + # Check for service first _windows_service_installed = False if is_windows(): from hermes_cli import gateway_windows + _windows_service_installed = gateway_windows.is_installed() - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): systemd_status(deep, system=system, full=full) _print_gateway_process_mismatch(snapshot) elif is_macos() and get_launchd_plist_path().exists(): @@ -5664,6 +6382,7 @@ def _gateway_command_inner(args): _print_gateway_process_mismatch(snapshot) elif _windows_service_installed: from hermes_cli import gateway_windows + gateway_windows.status(deep=deep) _print_gateway_process_mismatch(snapshot) else: @@ -5684,10 +6403,16 @@ def _gateway_command_inner(args): print(" Android may stop background jobs when Termux is suspended") elif is_wsl(): print("WSL note:") - print(" The gateway is running in foreground/manual mode (recommended for WSL).") - print(" Use tmux or screen for persistence across terminal closes.") + print( + " The gateway is running in foreground/manual mode (recommended for WSL)." + ) + print( + " Use tmux or screen for persistence across terminal closes." + ) elif is_windows(): - print("To install as a Windows Scheduled Task (auto-start on login):") + print( + "To install as a Windows Scheduled Task (auto-start on login):" + ) print(" hermes gateway install") else: print("To install as a service:") @@ -5705,15 +6430,25 @@ def _gateway_command_inner(args): print("To start:") print(" hermes gateway run # Run in foreground") if is_termux(): - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start") + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start" + ) elif is_wsl(): - print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") + print( + " tmux new -s hermes 'hermes gateway run' # persistent via tmux" + ) + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background" + ) elif is_windows(): - print(" hermes gateway install # Install as Windows Scheduled Task (auto-start on login)") + print( + " hermes gateway install # Install as Windows Scheduled Task (auto-start on login)" + ) else: print(" hermes gateway install # Install as user service") - print(" sudo hermes gateway install --system # Install as boot-time system service") + print( + " sudo hermes gateway install --system # Install as boot-time system service" + ) # Show other profiles' gateway status for multi-profile awareness _print_other_profiles_gateway_status() @@ -5725,8 +6460,8 @@ def _gateway_command_inner(args): # Stop, disable, and remove legacy Hermes gateway unit files from # pre-rename installs (e.g. hermes.service). Profile units and # unrelated third-party services are never touched. - dry_run = getattr(args, 'dry_run', False) - yes = getattr(args, 'yes', False) + dry_run = getattr(args, "dry_run", False) + yes = getattr(args, "yes", False) if not supports_systemd_services() and not is_macos(): print("Legacy unit migration only applies to systemd-based Linux hosts.") return diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 5cf32d1c8..1e7fc8620 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -114,6 +114,7 @@ def build_models_payload( include_unconfigured: bool = False, picker_hints: bool = False, canonical_order: bool = False, + pricing: bool = False, max_models: int = 50, ) -> dict: """Build the ``{providers, model, provider}`` shape every consumer @@ -128,6 +129,11 @@ def build_models_payload( - ``canonical_order``: reorder canonical-slug rows to ``CANONICAL_PROVIDERS`` declaration order; truly-custom rows go last (TUI display order). + - ``pricing``: enrich each row with formatted per-model pricing and, + for Nous, ``free_tier``/``unavailable_models`` so the GUI picker can + show $/Mtok columns and gate paid models on free accounts — + mirroring the ``hermes model`` CLI picker. Adds network calls + (pricing fetch + Nous tier check); only set for interactive pickers. """ from hermes_cli.model_switch import list_authenticated_providers @@ -146,6 +152,8 @@ def build_models_payload( _apply_picker_hints(rows) if canonical_order: rows = _reorder_canonical(rows) + if pricing: + _apply_pricing(rows) return { "providers": rows, @@ -238,3 +246,85 @@ def _reorder_canonical(rows: list[dict]) -> list[dict]: ) extras = [r for r in rows if r["slug"] not in order] return canon + extras + + +def _apply_pricing(rows: list[dict]) -> None: + """Enrich each provider row with per-model pricing + Nous tier gating. + + Mutates ``rows`` in-place. For every row whose provider supports live + pricing (openrouter / nous / novita) adds:: + + row["pricing"] = {model_id: {"input": "$3.00", "output": "$15.00", + "cache": "$0.30" | None, "free": bool}} + + For Nous additionally adds:: + + row["free_tier"] = bool # current account is free-tier + row["unavailable_models"] = [...] # paid models a free user can't pick + + Prices are pre-formatted via ``_format_price_per_mtok`` so the GUI just + renders strings — identical formatting to the CLI picker. All failures + are swallowed (best-effort): a row simply gets no ``pricing`` key. + """ + from hermes_cli.models import ( + _format_price_per_mtok, + check_nous_free_tier, + get_pricing_for_provider, + partition_nous_models_by_tier, + ) + + # Resolve Nous free-tier once (cached in models.py for the TTL window). + nous_free_tier: Optional[bool] = None + + for row in rows: + slug = str(row.get("slug", "")).lower() + models = row.get("models") or [] + if not models: + continue + try: + raw_pricing = get_pricing_for_provider(slug) or {} + except Exception: + raw_pricing = {} + if not raw_pricing: + continue + + formatted: dict[str, dict] = {} + for mid in models: + p = raw_pricing.get(mid) + if not p: + continue + inp_raw = p.get("prompt", "") + out_raw = p.get("completion", "") + cache_raw = p.get("input_cache_read", "") + inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else "" + out = _format_price_per_mtok(out_raw) if out_raw != "" else "" + cache = _format_price_per_mtok(cache_raw) if cache_raw else None + # A model is "free" when both input and output cost nothing. + is_free = inp == "free" and (out == "free" or out == "") + formatted[mid] = { + "input": inp, + "output": out, + "cache": cache, + "free": is_free, + } + + if formatted: + row["pricing"] = formatted + + if slug == "nous": + try: + if nous_free_tier is None: + nous_free_tier = check_nous_free_tier(force_fresh=True) + row["free_tier"] = bool(nous_free_tier) + if nous_free_tier: + _selectable, unavailable = partition_nous_models_by_tier( + list(models), raw_pricing, free_tier=True + ) + row["unavailable_models"] = unavailable + else: + row["unavailable_models"] = [] + except Exception: + # Tier detection failed — fail open (no gating) so the user + # is never blocked from picking a model. + row["free_tier"] = False + row["unavailable_models"] = [] diff --git a/hermes_cli/logs.py b/hermes_cli/logs.py index 9a829a4bd..d580751b4 100644 --- a/hermes_cli/logs.py +++ b/hermes_cli/logs.py @@ -10,6 +10,7 @@ Usage examples:: hermes logs -f # follow agent.log in real time hermes logs errors # last 50 lines of errors.log hermes logs gateway -n 100 # last 100 lines of gateway.log + hermes logs gui -f # follow gui.log (dashboard/pty/ws) hermes logs --level WARNING # only WARNING+ lines hermes logs --session abc123 # filter by session ID substring hermes logs --component tools # only tool-related lines @@ -31,6 +32,7 @@ LOG_FILES = { "agent": "agent.log", "errors": "errors.log", "gateway": "gateway.log", + "gui": "gui.log", } # Log line timestamp regex — matches "2026-04-05 22:35:00,123" or @@ -150,7 +152,7 @@ def tail_log( Parameters ---------- log_name - Which log to read: ``"agent"``, ``"errors"``, ``"gateway"``. + Which log to read: ``"agent"``, ``"errors"``, ``"gateway"``, ``"gui"``. num_lines Number of recent lines to show (before follow starts). follow diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9b9e18580..1c3c6c202 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -381,10 +381,19 @@ except Exception: # Initialize centralized file logging early — all `hermes` subcommands # (chat, setup, gateway, config, etc.) write to agent.log + errors.log. +# Dashboard entrypoints bootstrap with GUI mode so gui.log is always present +# during GUI testing, including pre-dispatch startup failures. try: from hermes_logging import setup_logging as _setup_logging - _setup_logging(mode="cli") + _setup_logging( + mode=( + "gui" + if next((arg for arg in sys.argv[1:] if not arg.startswith("-")), "") + in {"dashboard", "gui", "desktop"} + else "cli" + ) + ) except Exception: pass # best-effort — don't crash the CLI if logging setup fails @@ -1701,6 +1710,26 @@ def _pin_kanban_board_env() -> None: pass +def _sync_bundled_skills_quietly() -> None: + """Seed ``~/.hermes/skills/`` with the bundled skill library on first launch. + + Called from any CLI entrypoint that the user might use as their first + interaction with Hermes — chat, dashboard (the desktop GUI's backend), + and gateway. The skills_sync module is manifest-based and idempotent: + skipped skills cost ~milliseconds, so calling this repeatedly is fine. + + Failures are swallowed because skills are an enhancement, not a hard + dependency. Hermes still functions without them; the user just sees an + empty skills library. + """ + try: + from tools.skills_sync import sync_skills + + sync_skills(quiet=True) + except Exception: + pass + + def cmd_chat(args): """Run interactive chat CLI.""" use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" @@ -1888,6 +1917,8 @@ def cmd_chat(args): def cmd_gateway(args): """Gateway management commands.""" + _sync_bundled_skills_quietly() + from hermes_cli.gateway import gateway_command gateway_command(args) @@ -6546,12 +6577,16 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) def _web_ui_build_needed(web_dir: Path) -> bool: """Return True if the web UI dist is missing or stale. - The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts - outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite - manifest as the sentinel because it is written last and therefore has the - newest mtime of any build output. + Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI. + The dashboard source lives under ``web/``, but the Vite build + still outputs to ``hermes_cli/web_dist/`` (per vite.config.ts + outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``, so Python + packaging can continue serving the same static asset directory. Uses the + Vite manifest as the sentinel because it is written last and therefore + has the newest mtime of any build output. """ - dist_dir = web_dir.parent / "hermes_cli" / "web_dist" + project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent + dist_dir = project_root / "hermes_cli" / "web_dist" sentinel = dist_dir / ".vite" / "manifest.json" if not sentinel.exists(): sentinel = dist_dir / "index.html" @@ -6725,7 +6760,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: """Build the web UI frontend if npm is available. Args: - web_dir: Path to the ``web/`` source directory. + web_dir: Path to the dashboard frontend source directory. fatal: If True, print error guidance and return False on failure instead of a soft warning (used by ``hermes web``). @@ -6803,7 +6838,8 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: build_output = (r2.stderr or "") + (r2.stdout or "") stderr_preview = build_output.strip() stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else "" - dist_dir = web_dir.parent / "hermes_cli" / "web_dist" + project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent + dist_dir = project_root / "hermes_cli" / "web_dist" dist_index = dist_dir / "index.html" # If a stale dist exists, serve it as a fallback instead of failing. @@ -6827,6 +6863,143 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: return True +def _desktop_dist_exists(desktop_dir: Path) -> bool: + """Return True when a local desktop renderer build is present.""" + return (desktop_dir / "dist" / "index.html").exists() + + +def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]: + """Return the current platform's unpacked Electron app executable.""" + release_dir = desktop_dir / "release" + if sys.platform == "darwin": + candidates = list(release_dir.glob("mac*/Hermes.app/Contents/MacOS/Hermes")) + elif sys.platform == "win32": + candidates = [ + release_dir / "win-unpacked" / "Hermes.exe", + release_dir / "win-ia32-unpacked" / "Hermes.exe", + release_dir / "win-arm64-unpacked" / "Hermes.exe", + ] + else: + candidates = [ + release_dir / "linux-unpacked" / "hermes", + release_dir / "linux-unpacked" / "Hermes", + ] + + existing = [p for p in candidates if p.exists()] + if not existing: + return None + return max(existing, key=lambda p: p.stat().st_mtime) + + +def cmd_gui(args): + """Build and launch the native Electron desktop GUI.""" + desktop_dir = PROJECT_ROOT / "apps" / "desktop" + if not (desktop_dir / "package.json").exists(): + print(f"Desktop GUI source not found at: {desktop_dir}") + sys.exit(1) + + try: + from hermes_logging import setup_logging as _setup_logging_gui + _setup_logging_gui(mode="gui") + except Exception: + pass + + env = os.environ.copy() + if getattr(args, "fake_boot", False): + env["HERMES_DESKTOP_BOOT_FAKE"] = "1" + if getattr(args, "ignore_existing", False): + env["HERMES_DESKTOP_IGNORE_EXISTING"] = "1" + if getattr(args, "hermes_root", None): + env["HERMES_DESKTOP_HERMES_ROOT"] = str(Path(args.hermes_root).expanduser().resolve()) + if getattr(args, "cwd", None): + env["HERMES_DESKTOP_CWD"] = str(Path(args.cwd).expanduser().resolve()) + + source_mode = getattr(args, "source", False) + skip_build = getattr(args, "skip_build", False) + packaged_executable = _desktop_packaged_executable(desktop_dir) + + if source_mode or not skip_build: + npm = shutil.which("npm") + if not npm: + print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.") + print("Install Node.js, then run: hermes gui") + sys.exit(1) + else: + npm = None + + if getattr(args, "skip_build", False): + if source_mode: + if not _desktop_dist_exists(desktop_dir): + print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}") + print(" Pre-build first: cd apps/desktop && npm run build") + print(" Or drop --skip-build to install dependencies and build automatically.") + sys.exit(1) + if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists(): + print("✗ --skip-build --source requires existing workspace dependencies.") + print(f" Install first: cd {PROJECT_ROOT} && npm ci") + print(" Or drop --skip-build to install dependencies and build automatically.") + sys.exit(1) + print(f"→ Skipping desktop source build (--skip-build --source); using dist at {desktop_dir / 'dist'}") + elif packaged_executable is None: + print(f"✗ --skip-build was passed but no packaged desktop app was found at: {desktop_dir / 'release'}") + print(" Pre-build first: cd apps/desktop && npm run pack") + print(" Or drop --skip-build to package automatically.") + sys.exit(1) + else: + print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}") + else: + print("→ Installing desktop workspace dependencies...") + install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False) + if install_result.returncode != 0: + print("✗ Desktop dependency install failed") + print(f" Run manually: cd {PROJECT_ROOT} && npm ci") + sys.exit(install_result.returncode or 1) + + build_label = "source build" if source_mode else "packaged app" + print(f"→ Building desktop {build_label}...") + build_script = "build" if source_mode else "pack" + build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False) + if build_result.returncode != 0: + print("✗ Desktop GUI build failed") + print(f" Run manually: cd apps/desktop && npm run {build_script}") + sys.exit(build_result.returncode or 1) + packaged_executable = _desktop_packaged_executable(desktop_dir) + + # --build-only: produce the artifact but do NOT launch. The installer's + # --update flow drives the rebuild headlessly and then launches the desktop + # itself (detached, after the old exe has exited), so the launch must NOT + # happen here — it would block the installer and, on Windows, the old exe + # is still being replaced. Verify the expected artifact exists so a silent + # "built nothing" can't slip past, then return success. + if getattr(args, "build_only", False): + if source_mode: + if not _desktop_dist_exists(desktop_dir): + print(f"✗ --build-only --source produced no dist at: {desktop_dir / 'dist'}") + sys.exit(1) + print(f"✓ Desktop source build ready at {desktop_dir / 'dist'} (not launching; --build-only)") + elif packaged_executable is None: + print(f"✗ --build-only produced no launchable app at: {desktop_dir / 'release'}") + print(" Expected an unpacked Electron app for the current OS.") + sys.exit(1) + else: + print(f"✓ Desktop packaged app ready: {packaged_executable} (not launching; --build-only)") + return + + if source_mode: + print("→ Launching Hermes Desktop from source build...") + launch_result = subprocess.run([npm, "exec", "--", "electron", "."], cwd=desktop_dir, env=env, check=False) + sys.exit(launch_result.returncode) + + if packaged_executable is None: + print(f"✗ Desktop package build completed but no launchable app was found at: {desktop_dir / 'release'}") + print(" Expected an unpacked Electron app for the current OS.") + sys.exit(1) + + print(f"→ Launching packaged Hermes Desktop: {packaged_executable}") + launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False) + sys.exit(launch_result.returncode) + + def _find_stale_dashboard_pids() -> list[int]: """Return PIDs of ``hermes dashboard`` processes other than ourselves. @@ -7292,11 +7465,6 @@ def _update_via_zip(args): _install_python_dependencies_with_optional_fallback(pip_cmd) _update_node_dependencies() - # Core (Python deps + git pull / ZIP extract) is now complete; the CLI - # is functional from this point onward. The web UI build below is - # optional — a failure here only affects ``hermes dashboard``. Make - # that visible so users don't panic and reboot mid-build (#33788). - print("→ Core update complete. Building dashboard (optional)...") _build_web_ui(PROJECT_ROOT / "web") # Sync skills @@ -8404,10 +8572,19 @@ def _update_node_dependencies() -> None: # appearing to hang silently for minutes (#18840). The # `_UpdateOutputStream` wrapper installed by the updater mirrors # streamed output to ``~/.hermes/logs/update.log`` so nothing is lost. + # + # The repo root install also passes `--workspaces=false` so npm + # does not recursively install every `apps/*` workspace (dashboard, + # desktop, shared) — those are installed/built on demand via + # `_build_web_ui()` and the desktop launchers. + extra_args = ["--no-fund", "--no-audit", "--progress=false"] + if path == PROJECT_ROOT: + extra_args.append("--workspaces=false") + result = _run_npm_install_deterministic( npm, path, - extra_args=("--no-fund", "--no-audit", "--progress=false"), + extra_args=tuple(extra_args), capture_output=False, ) if result.returncode == 0: @@ -8922,6 +9099,43 @@ def _run_pre_update_backup(args) -> None: print() +def _discard_lockfile_churn(git_cmd, repo_root): + """Restore tracked ``package-lock.json`` files that npm dirtied locally. + + npm rewrites lockfiles non-deterministically at install/build time. On a + managed install those diffs are never intentional, so we discard them so + ``hermes update`` sees a clean tree instead of autostashing every run. + Best-effort; only ever touches files named ``package-lock.json``. + """ + try: + diff = subprocess.run( + git_cmd + ["diff", "--name-only"], + cwd=repo_root, + capture_output=True, + text=True, + ) + if diff.returncode != 0: + return + dirty = [ + line.strip() + for line in diff.stdout.splitlines() + if line.strip().endswith("package-lock.json") + ] + if not dirty: + return + subprocess.run( + git_cmd + ["checkout", "--", *dirty], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))") + except Exception: + # Never let lockfile cleanup block an update. + pass + + def cmd_update(args): """Update Hermes Agent to the latest version. @@ -9099,6 +9313,15 @@ def _cmd_update_impl(args, gateway_mode: bool): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] + # Discard npm lockfile churn before any stash/branch logic. npm rewrites + # tracked package-lock.json files non-deterministically at install/build + # time (platform-specific optional deps, ideallyInert annotations, etc.), + # which is never an intentional edit on a managed install but leaves the + # tree dirty — forcing an autostash on every update and making branch + # switches fragile. Restoring them first lets the common case (only + # lockfile churn) update with a clean tree. + _discard_lockfile_churn(git_cmd, PROJECT_ROOT) + # Detect if we're updating from a fork (before any branch logic) origin_url = _get_origin_url(git_cmd, PROJECT_ROOT) is_fork = _is_fork(origin_url) @@ -9429,10 +9652,6 @@ def _cmd_update_impl(args, gateway_mode: bool): _refresh_active_lazy_features() _update_node_dependencies() - # See note above (ZIP path): core is now complete, web UI build is - # optional from a CLI perspective. Telegraphing this avoids the - # "stuck at webui-build → reboot → broken install" trap (#33788). - print("→ Core update complete. Building dashboard (optional)...") _build_web_ui(PROJECT_ROOT / "web") print() @@ -10340,6 +10559,8 @@ def _coalesce_session_name_args(argv: list) -> list: "uninstall", "profile", "dashboard", + "desktop", + "gui", "honcho", "claw", "plugins", @@ -11069,6 +11290,14 @@ def cmd_dashboard(args): remaining = _find_stale_dashboard_pids() sys.exit(1 if remaining else 0) + # Attach gui.log early so dashboard startup/build failures are captured in + # the same logs directory as every other Hermes surface. + try: + from hermes_logging import setup_logging as _setup_logging_gui + _setup_logging_gui(mode="gui") + except Exception: + pass + try: import fastapi # noqa: F401 import uvicorn # noqa: F401 @@ -11083,6 +11312,12 @@ def cmd_dashboard(args): print(f"Import error: {e}") sys.exit(1) + # Seed bundled skills on first dashboard launch so the desktop GUI's + # skills picker / agent skill discovery sees the bundled library. + # cmd_chat does this in its own pre-dispatch block; the dashboard + # backend is the desktop's primary entrypoint and needs the same. + _sync_bundled_skills_quietly() + if "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False): if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): sys.exit(1) @@ -11184,7 +11419,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "computer-use", "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", - "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", + "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy", "prompt-size", "send", "sessions", "setup", @@ -14400,13 +14635,67 @@ Examples: ) dashboard_parser.set_defaults(func=cmd_dashboard) + # ========================================================================= + # desktop (a.k.a. gui) command + # + # The canonical name is "desktop"; "gui" is kept as a deprecated alias + # for one release. The Hermes-Setup.exe success screen tells users to + # run `hermes desktop` from a terminal, so the canonical name needs + # to be the one that appears in --help (argparse promotes the primary + # name; aliases stay hidden). + # ========================================================================= + gui_parser = subparsers.add_parser( + "desktop", + aliases=["gui"], + help="Build and launch the native desktop app", + description=( + "Launch the Hermes Electron desktop app. By default this installs " + "workspace Node dependencies, builds the current OS's unpacked " + "Electron app, then launches that packaged artifact." + ), + ) + gui_parser.add_argument( + "--skip-build", + action="store_true", + help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release", + ) + gui_parser.add_argument( + "--source", + action="store_true", + help="Launch via `electron .` against apps/desktop/dist instead of the packaged app", + ) + gui_parser.add_argument( + "--build-only", + action="store_true", + help="Build the desktop app but do not launch it (used by the installer's --update flow)", + ) + gui_parser.add_argument( + "--fake-boot", + action="store_true", + help="Enable deterministic desktop boot delays for validating startup UI", + ) + gui_parser.add_argument( + "--ignore-existing", + action="store_true", + help="Force Desktop to ignore any hermes CLI already on PATH during backend resolution", + ) + gui_parser.add_argument( + "--hermes-root", + help="Override the Hermes source root used by Desktop (sets HERMES_DESKTOP_HERMES_ROOT)", + ) + gui_parser.add_argument( + "--cwd", + help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)", + ) + gui_parser.set_defaults(func=cmd_gui) + # ========================================================================= # logs command # ========================================================================= logs_parser = subparsers.add_parser( "logs", help="View and filter Hermes log files", - description="View, tail, and filter agent.log / errors.log / gateway.log", + description="View, tail, and filter agent.log / errors.log / gateway.log / gui.log", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: @@ -14414,6 +14703,7 @@ Examples: hermes logs -f Follow agent.log in real time hermes logs errors Show last 50 lines of errors.log hermes logs gateway -n 100 Show last 100 lines of gateway.log + hermes logs gui -f Follow gui.log in real time hermes logs --level WARNING Only show WARNING and above hermes logs --session abc123 Filter by session ID hermes logs --component tools Only show tool-related lines @@ -14426,7 +14716,7 @@ Examples: "log_name", nargs="?", default="agent", - help="Log to view: agent (default), errors, gateway, or 'list' to show available files", + help="Log to view: agent (default), errors, gateway, gui, or 'list' to show available files", ) logs_parser.add_argument( "-n", @@ -14459,7 +14749,7 @@ Examples: logs_parser.add_argument( "--component", metavar="NAME", - help="Filter by component: gateway, agent, tools, cli, cron", + help="Filter by component: gateway, agent, tools, cli, cron, gui", ) logs_parser.set_defaults(func=cmd_logs) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 5fb786127..60d4d7976 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1359,6 +1359,43 @@ def list_authenticated_providers( model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, [])) except Exception: model_ids = curated.get(hermes_slug, []) or curated.get(pid, []) + elif hermes_slug == "nous": + # Nous serves a large live /v1/models catalog (vendor-prefixed + # models from many providers, returned alphabetically). The + # `hermes model` picker deliberately shows ONLY the curated agentic + # list — augmented with the Portal's free/paid recommendations so + # newly-launched models surface without a CLI release — in curated + # order. Mirror that exactly (see _model_flow_nous in main.py) so + # the GUI picker matches the CLI. Was: falling through to + # cached_provider_model_ids, which dumped the full alphabetical + # catalog; then: curated-only, which dropped the 4 Portal + # recommendations (e.g. stepfun/step-3.7-flash:free). + model_ids = curated.get("nous", []) + try: + from hermes_cli.models import ( + get_pricing_for_provider as _nous_pricing, + check_nous_free_tier as _nous_free, + union_with_portal_free_recommendations as _union_free, + union_with_portal_paid_recommendations as _union_paid, + ) + from hermes_cli.auth import get_provider_auth_state as _nous_state + + _pricing = _nous_pricing("nous") or {} + _portal = "" + try: + _st = _nous_state("nous") or {} + _portal = _st.get("portal_base_url", "") or "" + except Exception: + _portal = "" + if _nous_free(force_fresh=True): + model_ids, _ = _union_free(model_ids, _pricing, _portal) + else: + model_ids, _ = _union_paid(model_ids, _pricing, _portal) + except Exception: + # Portal recommendation fetch failed — fall back to the + # curated list alone (still correct, just may lag newly + # launched models, exactly like an offline CLI run). + pass else: # Unified pathway — see Section 1 rationale. Fall back to the # curated dict (with models.dev merge for preferred providers) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index f8d2184e6..ddbd0402f 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -729,85 +729,55 @@ def _plugin_exists(name: str) -> bool: def _discover_all_plugins() -> list: - """Return a list of (key, version, description, source, dir_path) for - every plugin the loader can see — user + bundled. + """Return a list of (name, version, description, source, dir_path) for + every plugin the loader can see — user + bundled + project. - Mirrors :meth:`PluginManager._scan_directory_level` so category-namespaced - plugins (``observability/langfuse``, ``image_gen/openai``) surface here - just like flat ones (``disk-cleanup``). A subdirectory with no - ``plugin.yaml`` of its own is treated as a category and recursed into - one level deeper (depth capped at 2, same as the loader). - - The returned ``key`` is the path-derived registry key — the value the - user types into ``hermes plugins enable <key>``. For category-namespaced - plugins that's ``<category>/<dirname>``; for flat plugins it's the - manifest's ``name`` (or the directory name if the manifest omits it). - - User entries override bundled on key collision, matching - ``PluginManager.discover_and_load``. + Matches the ordering/dedup of ``PluginManager.discover_and_load``: + bundled first, then user, then project; user overrides bundled on + name collision. """ try: import yaml except ImportError: yaml = None - seen: dict = {} # key -> (key, version, description, source, path) + seen: dict = {} # name -> (name, version, description, source, path) - def _scan(base: Path, source: str, prefix: str, depth: int) -> None: + # Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/ + from hermes_cli.plugins import get_bundled_plugins_dir + repo_plugins = get_bundled_plugins_dir() + for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")): if not base.is_dir(): - return + continue for d in sorted(base.iterdir()): if not d.is_dir(): continue - if ( - depth == 0 - and source == "bundled" - and d.name in {"memory", "context_engine"} - ): + if source == "bundled" and d.name in {"memory", "context_engine"}: continue manifest_file = d / "plugin.yaml" if not manifest_file.exists(): manifest_file = d / "plugin.yml" - - if manifest_file.exists(): - manifest_name = d.name - version = "" - description = "" - if yaml: - try: - with open(manifest_file, encoding="utf-8") as f: - manifest = yaml.safe_load(f) or {} - manifest_name = manifest.get("name", d.name) - version = manifest.get("version", "") - description = manifest.get("description", "") - except Exception: - pass - # Path-derived key, intentionally ignoring the manifest - # ``name:`` field for category-namespaced plugins — mirrors - # ``PluginManager._parse_manifest`` in plugins.py:1027-1028 - # so renaming a directory (without touching plugin.yaml) shifts - # the registry key in both places consistently. - key = f"{prefix}/{d.name}" if prefix else manifest_name - src_label = source - if source == "user" and (d / ".git").exists(): - src_label = "git" - # Bundled is scanned before user, so the user pass overwrites - # bundled entries with the same key — matches - # PluginManager.discover_and_load's "user wins" semantics. - seen[key] = (key, version, description, src_label, d) + if not manifest_file.exists(): continue - - # No manifest at this level — treat as a category namespace and - # recurse one level deeper. Cap at depth 2 (same as the loader). - if depth >= 1: + name = d.name + version = "" + description = "" + if yaml: + try: + with open(manifest_file, encoding="utf-8") as f: + manifest = yaml.safe_load(f) or {} + name = manifest.get("name", d.name) + version = manifest.get("version", "") + description = manifest.get("description", "") + except Exception: + pass + # User plugins override bundled on name collision. + if name in seen and source == "bundled": continue - sub_prefix = f"{prefix}/{d.name}" if prefix else d.name - _scan(d, source, sub_prefix, depth + 1) - - from hermes_cli.plugins import get_bundled_plugins_dir - _scan(get_bundled_plugins_dir(), "bundled", "", 0) - _scan(_plugins_dir(), "user", "", 0) - + src_label = source + if source == "user" and (d / ".git").exists(): + src_label = "git" + seen[name] = (name, version, description, src_label, d) return list(seen.values()) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5753ab83c..3b9747b6c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -501,7 +501,6 @@ def _print_setup_summary(config: dict, hermes_home): tool_status.append(("Text-to-Speech (NeuTTS — not installed)", False, "run 'hermes setup tts'")) elif tts_provider == "kittentts": try: - import importlib.util kittentts_ok = importlib.util.find_spec("kittentts") is not None except Exception: kittentts_ok = False @@ -1093,7 +1092,6 @@ def _setup_tts_provider(config: dict): elif selected == "kittentts": # Check if already installed try: - import importlib.util already_installed = importlib.util.find_spec("kittentts") is not None except Exception: already_installed = False diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 8322ff0d8..b447c880c 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -494,6 +494,31 @@ TOOL_CATEGORIES = { }, ], }, + "langfuse": { + "name": "Langfuse Observability", + "icon": "📊", + "providers": [ + { + "name": "Langfuse Cloud", + "tag": "Hosted Langfuse (cloud.langfuse.com)", + "env_vars": [ + {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"}, + {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"}, + ], + "post_setup": "langfuse", + }, + { + "name": "Langfuse Self-Hosted", + "tag": "Self-hosted Langfuse instance", + "env_vars": [ + {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"}, + {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"}, + {"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"}, + ], + "post_setup": "langfuse", + }, + ], + }, } # Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES. @@ -879,35 +904,21 @@ def _run_post_setup(post_setup_key: str): camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser" _npm_bin = shutil.which("npm") if not camofox_dir.exists() and _npm_bin: - _print_info(" Installing Camofox browser package...") - _print_info(" First run downloads the Camoufox engine (~300MB) — this can take several minutes.") + _print_info(" Installing Camofox browser server...") import subprocess - # Install @askjo/camofox-browser on-demand. It is NOT in - # package.json so that `hermes update` does not silently pull - # the ~300MB Camoufox Firefox-fork binary for every user. - # Stream output (no capture, no --silent) so the long-running - # postinstall download is visible instead of looking frozen. - try: - result = subprocess.run( - [_npm_bin, "install", "@askjo/camofox-browser@^1.5.2", - "--no-fund", "--no-audit", "--progress=false"], - cwd=str(PROJECT_ROOT), - ) - if result.returncode == 0: - _print_success(" Camofox installed") - else: - _print_warning( - " npm install failed — run manually: " - "npm install @askjo/camofox-browser" - ) - except Exception as exc: - _print_warning(f" Camofox install failed: {exc}") - _print_info( - " Run manually: npm install @askjo/camofox-browser" - ) + # Absolute npm path so .cmd shim executes on Windows. + result = subprocess.run( + [_npm_bin, "install", "--silent"], + capture_output=True, text=True, cwd=str(PROJECT_ROOT) + ) + if result.returncode == 0: + _print_success(" Camofox installed") + else: + _print_warning(" npm install failed - run manually: npm install") if camofox_dir.exists(): _print_info(" Start the Camofox server:") _print_info(" npx @askjo/camofox-browser") + _print_info(" First run downloads the Camoufox engine (~300MB)") _print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") elif not shutil.which("npm"): _print_warning(" Node.js not found. Install Camofox via Docker:") @@ -1016,6 +1027,36 @@ def _run_post_setup(post_setup_key: str): _print_warning(f" Spotify login failed: {exc}") _print_info(" Run manually: hermes auth spotify") + elif post_setup_key == "langfuse": + # Install the langfuse SDK. + try: + __import__("langfuse") + _print_success(" langfuse SDK already installed") + except ImportError: + _print_info(" Installing langfuse SDK...") + result = _pip_install(["langfuse", "--quiet"], timeout=120) + if result.returncode == 0: + _print_success(" langfuse SDK installed") + else: + _print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse") + # Opt the bundled observability/langfuse plugin into plugins.enabled. + # The plugin ships in the repo but doesn't load until the user enables + # it (standalone plugins are opt-in). + try: + from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set + enabled = _get_enabled_set() + if "observability/langfuse" in enabled or "langfuse" in enabled: + _print_success(" Plugin observability/langfuse already enabled") + else: + enabled.add("observability/langfuse") + _save_enabled_set(enabled) + _print_success(" Plugin observability/langfuse enabled") + except Exception as exc: + _print_warning(f" Could not enable plugin automatically: {exc}") + _print_info(" Run manually: hermes plugins enable observability/langfuse") + _print_info(" Restart Hermes for tracing to take effect.") + _print_info(" Verify: hermes plugins list") + elif post_setup_key == "xai_grok": # Shared credential bootstrap for any picker entry that talks to xAI # (TTS, Video Gen, future Image Gen, etc.). Accepts either a @@ -2553,6 +2594,107 @@ def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gat _configure_videogen_model_for_plugin(plugin_name, config) +def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> None: + """Persist the provider/backend config keys for a selected provider. + + This is the pure, non-interactive core of :func:`_configure_provider` — + it writes ``tts.provider`` / ``browser.cloud_provider`` / ``web.backend`` + and the ``use_gateway`` flags based on the provider's markers, but does + NOT prompt for env vars, run post-setup hooks, gate on Nous auth, or run + interactive model pickers. Both the CLI configurator and the desktop GUI + ``PUT .../provider`` endpoint call through here so there is one code path. + """ + # Set TTS provider in config if applicable + if provider.get("tts_provider"): + tts_cfg = config.setdefault("tts", {}) + tts_cfg["provider"] = provider["tts_provider"] + tts_cfg["use_gateway"] = bool(managed_feature) + + # Set browser cloud provider in config if applicable + if "browser_provider" in provider: + bp = provider["browser_provider"] + browser_cfg = config.setdefault("browser", {}) + if bp: + browser_cfg["cloud_provider"] = bp + browser_cfg["use_gateway"] = bool(managed_feature) + + # Set web search backend in config if applicable + if provider.get("web_backend"): + web_cfg = config.setdefault("web", {}) + web_cfg["backend"] = provider["web_backend"] + web_cfg["use_gateway"] = bool(managed_feature) + + # For tools without a specific config key (e.g. image_gen), still + # track use_gateway so the runtime knows the user's intent. + if managed_feature and managed_feature not in {"web", "tts", "browser"}: + config.setdefault(managed_feature, {})["use_gateway"] = True + elif not managed_feature: + # User picked a non-gateway provider — find which category this + # belongs to and clear use_gateway if it was previously set. + for cat_key, cat in TOOL_CATEGORIES.items(): + if provider in cat.get("providers", []): + section = config.get(cat_key) + if isinstance(section, dict) and section.get("use_gateway"): + section["use_gateway"] = False + break + + +def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> None: + """Non-interactively persist a provider selection for a toolset. + + Resolves ``provider_name`` within ``ts_key``'s category (matching the + rows the GUI/CLI picker shows via :func:`_visible_providers`) and writes + the corresponding backend/provider config keys. Unlike + :func:`_configure_provider`, this does NOT prompt for API keys, run + post-setup hooks, gate on Nous Portal auth, or run interactive model + pickers — those are handled separately (env endpoints, post-setup + endpoints, the model picker) in the desktop GUI. + + Raises ``KeyError`` if the toolset has no category or the provider name + is not found among the visible providers. + """ + cat = TOOL_CATEGORIES.get(ts_key) + if cat is None: + raise KeyError(f"Toolset has no configurable category: {ts_key}") + + providers = _visible_providers(cat, config, force_fresh=True) + provider = next((p for p in providers if p.get("name") == provider_name), None) + if provider is None: + raise KeyError(f"Unknown provider {provider_name!r} for toolset {ts_key!r}") + + managed_feature = provider.get("managed_nous_feature") + _write_provider_config(provider, config, managed_feature=managed_feature) + + # Plugin-registered image/video gen backends record the provider name in + # their own config section. Write that here (without the interactive + # model picker the CLI runs afterwards — model choice is a separate GUI + # flow). + plugin_name = provider.get("image_gen_plugin_name") + if plugin_name: + img_cfg = config.setdefault("image_gen", {}) + if not isinstance(img_cfg, dict): + img_cfg = {} + config["image_gen"] = img_cfg + img_cfg["provider"] = plugin_name + img_cfg["use_gateway"] = bool(managed_feature) + + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + vid_cfg = config.setdefault("video_gen", {}) + if not isinstance(vid_cfg, dict): + vid_cfg = {} + config["video_gen"] = vid_cfg + vid_cfg["provider"] = video_plugin + vid_cfg["use_gateway"] = bool(managed_feature) + + # In-tree FAL imagegen backend: keep image_gen.provider on the legacy + # path (mirrors _configure_provider). + if provider.get("imagegen_backend"): + img_cfg = config.setdefault("image_gen", {}) + if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}: + img_cfg["provider"] = "fal" + + def _configure_provider( provider: dict, config: dict, @@ -2606,35 +2748,19 @@ def _configure_provider( # Set browser cloud provider in config if applicable if "browser_provider" in provider: bp = provider["browser_provider"] - browser_cfg = config.setdefault("browser", {}) if bp == "local": - browser_cfg["cloud_provider"] = "local" _print_success(" Browser set to local mode") elif bp: - browser_cfg["cloud_provider"] = bp _print_success(f" Browser cloud provider set to: {bp}") - browser_cfg["use_gateway"] = bool(managed_feature) # Set web search backend in config if applicable if provider.get("web_backend"): - web_cfg = config.setdefault("web", {}) - web_cfg["backend"] = provider["web_backend"] - web_cfg["use_gateway"] = bool(managed_feature) _print_success(f" Web backend set to: {provider['web_backend']}") - # For tools without a specific config key (e.g. image_gen), still - # track use_gateway so the runtime knows the user's intent. - if managed_feature and managed_feature not in {"web", "tts", "browser"}: - config.setdefault(managed_feature, {})["use_gateway"] = True - elif not managed_feature: - # User picked a non-gateway provider — find which category this - # belongs to and clear use_gateway if it was previously set. - for cat_key, cat in TOOL_CATEGORIES.items(): - if provider in cat.get("providers", []): - section = config.get(cat_key) - if isinstance(section, dict) and section.get("use_gateway"): - section["use_gateway"] = False - break + # Persist the provider/backend config keys + use_gateway flags. Shared + # with the GUI provider-select endpoint via apply_provider_selection so + # there is a single source of truth for these writes. + _write_provider_config(provider, config, managed_feature=managed_feature) if not env_vars: if provider.get("post_setup"): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 6c488276a..666d3f9f2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -10,6 +10,8 @@ Usage: """ import asyncio +import base64 +import binascii import hmac import importlib.util import json @@ -19,6 +21,7 @@ import secrets import stat import subprocess import sys +import tempfile import threading import time import urllib.parse @@ -82,10 +85,13 @@ app = FastAPI(title="Hermes Agent", version=__version__) # --------------------------------------------------------------------------- # Session token for protecting sensitive endpoints (reveal). -# Generated fresh on every server start — dies when the process exits. -# Injected into the SPA HTML so only the legitimate web UI can use it. +# The desktop shell mints the token and injects it via +# HERMES_DASHBOARD_SESSION_TOKEN so its main process can authenticate the +# /api calls it makes on the user's behalf; otherwise we generate one fresh +# on every server start. Either way it dies when the process exits and is +# injected into the SPA HTML so only the legitimate web UI can use it. # --------------------------------------------------------------------------- -_SESSION_TOKEN = secrets.token_urlsafe(32) +_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32) _SESSION_HEADER_NAME = "X-Hermes-Session-Token" # In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui`` @@ -320,7 +326,14 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "stt.provider": { "type": "select", "description": "Speech-to-text provider", - "options": ["local", "openai", "mistral"], + # "mistral" temporarily removed — mistralai PyPI package quarantined + # (malicious 2.4.6 release on 2026-05-12). Restore once available. + "options": ["local", "groq", "openai", "xai", "elevenlabs"], + }, + "stt.elevenlabs.model_id": { + "type": "select", + "description": "ElevenLabs Scribe model", + "options": ["scribe_v2", "scribe_v1"], }, "display.skin": { "type": "select", @@ -493,6 +506,55 @@ class EnvVarReveal(BaseModel): key: str +class MessagingPlatformUpdate(BaseModel): + enabled: Optional[bool] = None + env: Dict[str, str] = {} + clear_env: List[str] = [] + + +class AudioTranscriptionRequest(BaseModel): + data_url: str + mime_type: Optional[str] = None + + +class ModelAssignment(BaseModel): + """Payload for POST /api/model/set — assign a provider/model to a slot. + + scope="main" → writes model.provider + model.default + scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model + scope="auxiliary" with task="" → applied to every auxiliary.* slot + scope="auxiliary" with task="__reset__" → resets every slot to provider="auto" + """ + + scope: str + provider: str + model: str + task: str = "" + + +_AUDIO_MIME_EXTENSIONS: Dict[str, str] = { + "audio/aac": ".aac", + "audio/flac": ".flac", + "audio/m4a": ".m4a", + "audio/mp3": ".mp3", + "audio/mp4": ".mp4", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/wave": ".wav", + "audio/webm": ".webm", + "audio/x-m4a": ".m4a", + "audio/x-wav": ".wav", + "video/webm": ".webm", +} +_MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024 + + +def _audio_extension_for_mime(mime_type: str) -> str: + normalized = (mime_type or "").split(";", 1)[0].strip().lower() + return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm") + + class ModelAssignment(BaseModel): """Payload for POST /api/model/set — assign a provider/model to a slot. @@ -791,6 +853,206 @@ async def update_hermes(): } +@app.post("/api/audio/transcribe") +async def transcribe_audio_upload(payload: AudioTranscriptionRequest): + data_url = (payload.data_url or "").strip() + if not data_url.startswith("data:") or "," not in data_url: + raise HTTPException(status_code=400, detail="Invalid audio payload") + + header, encoded = data_url.split(",", 1) + if ";base64" not in header: + raise HTTPException( + status_code=400, detail="Audio payload must be base64 encoded" + ) + + mime_type = ( + payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm" + ).strip() + normalized_mime_type = mime_type.split(";", 1)[0].lower() + if not ( + normalized_mime_type.startswith("audio/") + or normalized_mime_type == "video/webm" + ): + raise HTTPException( + status_code=400, detail="Payload must be an audio recording" + ) + + try: + audio_bytes = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="Audio payload is not valid base64") + + if not audio_bytes: + raise HTTPException(status_code=400, detail="Audio recording is empty") + if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="Audio recording is too large") + + temp_path = "" + try: + suffix = _audio_extension_for_mime(mime_type) + with tempfile.NamedTemporaryFile( + prefix="hermes-desktop-voice-", + suffix=suffix, + delete=False, + ) as tmp: + tmp.write(audio_bytes) + temp_path = tmp.name + + from tools.transcription_tools import transcribe_audio + + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, transcribe_audio, temp_path) + except HTTPException: + raise + except Exception as exc: + _log.exception("Desktop voice transcription failed") + raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}") + finally: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + pass + + if not result.get("success"): + raise HTTPException( + status_code=400, + detail=result.get("error") or "Transcription failed", + ) + + return { + "ok": True, + "transcript": str(result.get("transcript") or "").strip(), + "provider": result.get("provider"), + } + + +class TTSSpeakRequest(BaseModel): + text: str + + +def _elevenlabs_voice_label(voice: Dict[str, Any]) -> str: + name = str(voice.get("name") or voice.get("voice_id") or "Voice").strip() + category = str(voice.get("category") or "").strip() + + return f"{name} ({category})" if category else name + + +@app.get("/api/audio/elevenlabs/voices") +async def get_elevenlabs_voices(): + """Return ElevenLabs voices when an API key is configured. + + The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown. + Only non-secret voice metadata is returned; the API key stays server-side. + """ + api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip() + if not api_key: + return {"available": False, "voices": []} + + request = urllib.request.Request( + "https://api.elevenlabs.io/v1/voices", + headers={ + "Accept": "application/json", + "xi-api-key": api_key, + }, + ) + + try: + loop = asyncio.get_running_loop() + + def _fetch() -> Dict[str, Any]: + with urllib.request.urlopen(request, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + + payload = await loop.run_in_executor(None, _fetch) + except Exception as exc: + _log.warning("ElevenLabs voice list failed: %s", exc) + raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices") + + voices = [] + for voice in payload.get("voices") or []: + if not isinstance(voice, dict): + continue + + voice_id = str(voice.get("voice_id") or "").strip() + if not voice_id: + continue + + voices.append({ + "voice_id": voice_id, + "name": str(voice.get("name") or voice_id), + "label": _elevenlabs_voice_label(voice), + }) + + voices.sort(key=lambda item: str(item.get("label") or "").lower()) + return {"available": True, "voices": voices} + + +@app.post("/api/audio/speak") +async def speak_text(payload: TTSSpeakRequest): + """Synthesize speech and return audio as base64 data URL. + + Used by the desktop voice-conversation mode to play back assistant + responses without exposing the on-disk file path. Reuses the + existing TTS provider chain (Edge / OpenAI / ElevenLabs / etc.) + configured in ``~/.hermes/config.yaml`` under ``tts.``. + """ + text = (payload.text or "").strip() + if not text: + raise HTTPException(status_code=400, detail="Text is required") + + try: + from tools.tts_tool import text_to_speech_tool + loop = asyncio.get_running_loop() + result_json = await loop.run_in_executor(None, text_to_speech_tool, text) + except Exception as exc: + _log.exception("Desktop voice TTS failed") + raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {exc}") + + try: + result = json.loads(result_json) if isinstance(result_json, str) else result_json + except Exception: + raise HTTPException(status_code=500, detail="Invalid TTS response") + + if not result.get("success"): + raise HTTPException( + status_code=400, + detail=result.get("error") or "Speech synthesis failed", + ) + + file_path = result.get("file_path") + if not file_path or not os.path.isfile(file_path): + raise HTTPException(status_code=500, detail="Audio file missing") + + ext = os.path.splitext(file_path)[1].lower() + mime_type = { + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".wav": "audio/wav", + ".flac": "audio/flac", + }.get(ext, "audio/mpeg") + + try: + with open(file_path, "rb") as fh: + audio_bytes = fh.read() + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not read audio: {exc}") + finally: + try: + os.unlink(file_path) + except OSError: + pass + + encoded = base64.b64encode(audio_bytes).decode("ascii") + return { + "ok": True, + "data_url": f"data:{mime_type};base64,{encoded}", + "mime_type": mime_type, + "provider": result.get("provider"), + } + + @app.get("/api/actions/{name}/status") async def get_action_status(name: str, lines: int = 200): """Tail an action log and report whether the process is still running.""" @@ -821,13 +1083,16 @@ async def get_action_status(name: str, lines: int = 200): @app.get("/api/sessions") -async def get_sessions(limit: int = 20, offset: int = 0): +async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0): try: from hermes_state import SessionDB db = SessionDB() try: - sessions = db.list_sessions_rich(limit=limit, offset=offset) - total = db.session_count() + min_message_count = max(0, min_messages) + sessions = db.list_sessions_rich( + limit=limit, offset=offset, min_message_count=min_message_count + ) + total = db.session_count(min_message_count=min_message_count) now = time.time() for s in sessions: s["is_active"] = ( @@ -1046,12 +1311,84 @@ def get_model_options(): try: from hermes_cli.inventory import build_models_payload, load_picker_context - return build_models_payload(load_picker_context(), max_models=50) + return build_models_payload(load_picker_context(), max_models=50, pricing=True) except Exception: _log.exception("GET /api/model/options failed") raise HTTPException(status_code=500, detail="Failed to list model options") +@app.get("/api/model/recommended-default") +def get_recommended_default_model(provider: str = ""): + """Return the recommended default model for a freshly-authenticated provider. + + Mirrors the model-curation `hermes model` does so GUI onboarding lands on a + sensible default instead of blindly taking the first curated entry. For + Nous this honors the user's free/paid tier: free users get a free model, + paid users get the full curated default. For any other provider it falls + back to the first curated model (same as before). + + Response: {"provider": str, "model": str, "free_tier": bool | None} + where free_tier is True/False for Nous and None otherwise. `model` may be + empty if nothing could be resolved (caller degrades gracefully). + """ + slug = (provider or "").strip().lower() + + if slug == "nous": + try: + from hermes_cli.models import ( + get_curated_nous_model_ids, + get_pricing_for_provider, + check_nous_free_tier, + partition_nous_models_by_tier, + union_with_portal_free_recommendations, + union_with_portal_paid_recommendations, + ) + from hermes_cli.auth import get_provider_auth_state + + model_ids = get_curated_nous_model_ids() + pricing = get_pricing_for_provider("nous") or {} + free_tier = check_nous_free_tier(force_fresh=True) + + portal_url = "" + try: + state = get_provider_auth_state("nous") or {} + portal_url = state.get("portal_base_url", "") or "" + except Exception: + portal_url = "" + + if free_tier: + model_ids, pricing = union_with_portal_free_recommendations( + model_ids, pricing, portal_url + ) + model_ids, _unavailable = partition_nous_models_by_tier( + model_ids, pricing, free_tier=True + ) + else: + model_ids, pricing = union_with_portal_paid_recommendations( + model_ids, pricing, portal_url + ) + + model = model_ids[0] if model_ids else "" + return {"provider": "nous", "model": model, "free_tier": bool(free_tier)} + except Exception: + _log.exception("GET /api/model/recommended-default (nous) failed") + return {"provider": "nous", "model": "", "free_tier": None} + + # Non-Nous: first curated model for the provider, matching prior behaviour. + try: + from hermes_cli.inventory import build_models_payload, load_picker_context + + payload = build_models_payload(load_picker_context(), max_models=50) + for row in payload.get("providers", []): + if str(row.get("slug", "")).lower() == slug: + models = row.get("models") or [] + return {"provider": slug, "model": models[0] if models else "", "free_tier": None} + return {"provider": slug, "model": "", "free_tier": None} + except Exception: + _log.exception("GET /api/model/recommended-default failed") + return {"provider": slug, "model": "", "free_tier": None} + + @app.get("/api/model/auxiliary") def get_auxiliary_models(): """Return current auxiliary task assignments. @@ -1131,8 +1468,44 @@ async def set_model_assignment(body: ModelAssignment): if "context_length" in model_cfg: model_cfg.pop("context_length", None) cfg["model"] = model_cfg + + # When switching the main provider to Nous, mirror the CLI's + # post-model-selection behaviour (hermes_cli/main.py + # prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults): + # auto-route any *unconfigured* tools through the Nous Tool Gateway. + # This is purely additive — apply_nous_managed_defaults skips every + # tool where the user already has a direct key (FIRECRAWL_API_KEY, + # FAL_KEY, etc.) or an explicit backend/provider in config, so it + # never overwrites a user's own setup. GUI users thus land on the + # gateway the same way CLI users do, without a separate prompt. + gateway_tools: list[str] = [] + if provider.strip().lower() == "nous": + try: + from hermes_cli.nous_subscription import apply_nous_managed_defaults + from hermes_cli.tools_config import _get_platform_tools + + enabled = _get_platform_tools( + cfg, "cli", include_default_mcp_servers=False + ) + changed = apply_nous_managed_defaults( + cfg, + enabled_toolsets=enabled, + force_fresh=True, + ) + gateway_tools = sorted(changed) + except Exception: + # Portal lookup hiccups / non-subscriber / non-nous gating + # must never block saving the model assignment. + _log.debug("apply_nous_managed_defaults skipped", exc_info=True) + save_config(cfg) - return {"ok": True, "scope": "main", "provider": provider, "model": model} + return { + "ok": True, + "scope": "main", + "provider": provider, + "model": model, + "gateway_tools": gateway_tools, + } # scope == "auxiliary" aux = cfg.get("auxiliary") @@ -1281,6 +1654,74 @@ async def set_env_var(body: EnvVarUpdate): raise HTTPException(status_code=500, detail="Internal server error") +# Live credential probes keyed by env var. Each entry is (method, url, auth) +# where auth is "bearer" (Authorization header) or "query" (?key=). A cheap +# read-only models/key call that 401s on a bad token — enough to catch a +# mistyped key before it's persisted. Providers absent from this map (or local +# endpoints) are not network-validated; the client treats those as "unknown". +_CREDENTIAL_PROBES: dict[str, tuple[str, str]] = { + "OPENROUTER_API_KEY": ("https://openrouter.ai/api/v1/key", "bearer"), + "OPENAI_API_KEY": ("https://api.openai.com/v1/models", "bearer"), + "XAI_API_KEY": ("https://api.x.ai/v1/models", "bearer"), + "GEMINI_API_KEY": ("https://generativelanguage.googleapis.com/v1beta/models", "query"), +} + + +@app.post("/api/providers/validate") +async def validate_provider_credential(body: EnvVarUpdate, request: Request): + """Live-probe a provider credential before it's saved. + + Returns {ok, reachable, message}. ok=True means the provider accepted the + key; ok=False + reachable=True means the key is bad (caller should block); + reachable=False means the network probe couldn't run (caller may save with + a warning rather than hard-blocking offline users). + """ + _require_token(request) + import httpx + + key = (body.key or "").strip() + value = (body.value or "").strip() + if not value: + return {"ok": False, "reachable": True, "message": "Enter a value first."} + + # Local / custom endpoint: validate connectivity, not auth — any HTTP + # response (even 401) proves the endpoint is up. + if key == "OPENAI_BASE_URL": + url = value.rstrip("/") + "/models" + try: + with httpx.Client(timeout=httpx.Timeout(8.0)) as client: + client.get(url) + return {"ok": True, "reachable": True, "message": ""} + except Exception: + return {"ok": False, "reachable": False, "message": f"Could not reach {url}."} + + probe = _CREDENTIAL_PROBES.get(key) + if not probe: + # No probe for this provider — can't validate, don't block. + return {"ok": True, "reachable": False, "message": ""} + + url, auth = probe + headers = {"Accept": "application/json"} + params = {} + if auth == "bearer": + headers["Authorization"] = f"Bearer {value}" + else: + params["key"] = value + + try: + with httpx.Client(timeout=httpx.Timeout(10.0)) as client: + resp = client.get(url, headers=headers, params=params) + except Exception: + return {"ok": False, "reachable": False, "message": "Could not reach the provider to verify the key."} + + if resp.status_code in (401, 403): + return {"ok": False, "reachable": True, "message": "That API key was rejected. Double-check it and try again."} + if resp.status_code == 429 or resp.is_success: + # 429 = key is valid but rate-limited; success = valid. + return {"ok": True, "reachable": True, "message": ""} + return {"ok": False, "reachable": True, "message": f"Provider returned HTTP {resp.status_code} for this key."} + + @app.delete("/api/env") async def remove_env_var(body: EnvVarDelete): try: @@ -1325,6 +1766,667 @@ async def reveal_env_var(body: EnvVarReveal, request: Request): return {"key": body.key, "value": value} +# Entries omit fields they don't need to override; the catalog builder fills +# in env_vars from OPTIONAL_ENV_VARS via prefix matching when not specified, +# and pulls required_env from a plugin's PlatformEntry when available. +_PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { + "telegram": { + "name": "Telegram", + "description": "Run Hermes from Telegram DMs, groups, and topics.", + "docs_url": "https://core.telegram.org/bots/features#botfather", + "env_vars": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "TELEGRAM_PROXY"), + "required_env": ("TELEGRAM_BOT_TOKEN",), + }, + "discord": { + "name": "Discord", + "description": "Connect Hermes to Discord DMs, channels, and threads.", + "docs_url": "https://discord.com/developers/applications", + "env_vars": ( + "DISCORD_BOT_TOKEN", + "DISCORD_ALLOWED_USERS", + "DISCORD_REPLY_TO_MODE", + ), + "required_env": ("DISCORD_BOT_TOKEN",), + }, + "slack": { + "name": "Slack", + "description": "Use Hermes from Slack via Socket Mode.", + "docs_url": "https://api.slack.com/apps", + "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), + "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), + }, + "mattermost": { + "name": "Mattermost", + "description": "Connect Hermes to Mattermost channels and direct messages.", + "docs_url": "https://mattermost.com/deploy/", + "env_vars": ("MATTERMOST_URL", "MATTERMOST_TOKEN", "MATTERMOST_ALLOWED_USERS"), + "required_env": ("MATTERMOST_URL", "MATTERMOST_TOKEN"), + }, + "matrix": { + "name": "Matrix", + "description": "Use Hermes in Matrix rooms and direct messages.", + "docs_url": "https://matrix.org/ecosystem/servers/", + "env_vars": ( + "MATRIX_HOMESERVER", + "MATRIX_ACCESS_TOKEN", + "MATRIX_USER_ID", + "MATRIX_ALLOWED_USERS", + ), + "required_env": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"), + }, + "signal": { + "name": "Signal", + "description": "Connect through a signal-cli REST bridge.", + "docs_url": "https://github.com/bbernhard/signal-cli-rest-api", + "env_vars": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT", "SIGNAL_ALLOWED_USERS"), + "required_env": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"), + }, + "whatsapp": { + "name": "WhatsApp", + "description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.", + "docs_url": "https://github.com/tulir/whatsmeow", + "env_vars": ("WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS"), + "required_env": (), + }, + "homeassistant": { + "name": "Home Assistant", + "description": "Control your smart home from Hermes via Home Assistant.", + "docs_url": "https://www.home-assistant.io/docs/authentication/", + "env_vars": ("HASS_URL", "HASS_TOKEN"), + "required_env": ("HASS_URL", "HASS_TOKEN"), + }, + "email": { + "name": "Email", + "description": "Talk to Hermes through an IMAP/SMTP mailbox.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", + "env_vars": ( + "EMAIL_ADDRESS", + "EMAIL_PASSWORD", + "EMAIL_IMAP_HOST", + "EMAIL_SMTP_HOST", + ), + "required_env": ( + "EMAIL_ADDRESS", + "EMAIL_PASSWORD", + "EMAIL_IMAP_HOST", + "EMAIL_SMTP_HOST", + ), + }, + "sms": { + "name": "SMS (Twilio)", + "description": "Send and receive text messages via Twilio.", + "docs_url": "https://www.twilio.com/console", + "env_vars": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), + "required_env": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), + }, + "dingtalk": { + "name": "DingTalk", + "description": "Connect Hermes to DingTalk groups (钉钉).", + "docs_url": "https://open.dingtalk.com/document/orgapp/the-robot-development-process", + "env_vars": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), + "required_env": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), + }, + "feishu": { + "name": "Feishu / Lark", + "description": "Use Hermes inside Feishu / Lark.", + "docs_url": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/intro", + "env_vars": ( + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_ENCRYPT_KEY", + "FEISHU_VERIFICATION_TOKEN", + ), + "required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), + }, + "wecom": { + "name": "WeCom (group bot)", + "description": "Send-only WeCom group bot via webhook.", + "docs_url": "https://developer.work.weixin.qq.com/document/path/91770", + "env_vars": ("WECOM_BOT_ID", "WECOM_SECRET"), + "required_env": ("WECOM_BOT_ID",), + }, + "wecom_callback": { + "name": "WeCom (app)", + "description": "Two-way WeCom integration via callback app.", + "docs_url": "https://developer.work.weixin.qq.com/document/path/90930", + "env_vars": ( + "WECOM_CALLBACK_CORP_ID", + "WECOM_CALLBACK_CORP_SECRET", + "WECOM_CALLBACK_AGENT_ID", + "WECOM_CALLBACK_TOKEN", + "WECOM_CALLBACK_ENCODING_AES_KEY", + ), + "required_env": ( + "WECOM_CALLBACK_CORP_ID", + "WECOM_CALLBACK_CORP_SECRET", + "WECOM_CALLBACK_AGENT_ID", + ), + }, + "weixin": { + "name": "WeChat (Official Account)", + "description": "Connect a WeChat Official Account.", + "docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html", + "env_vars": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL"), + "required_env": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"), + }, + "bluebubbles": { + "name": "BlueBubbles (iMessage)", + "description": "Use Hermes through iMessage via a BlueBubbles server.", + "docs_url": "https://bluebubbles.app/", + "env_vars": ( + "BLUEBUBBLES_SERVER_URL", + "BLUEBUBBLES_PASSWORD", + "BLUEBUBBLES_ALLOWED_USERS", + ), + "required_env": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), + }, + "qqbot": { + "name": "QQ Bot", + "description": "Connect Hermes to a QQ Bot from the QQ Open Platform.", + "docs_url": "https://q.qq.com", + "env_vars": ("QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_ALLOWED_USERS"), + "required_env": ("QQ_APP_ID", "QQ_CLIENT_SECRET"), + }, + "yuanbao": { + "name": "Yuanbao (元宝)", + "description": "Connect Hermes to Tencent Yuanbao.", + "docs_url": "", + "required_env": (), + }, + "api_server": { + "name": "API server", + "description": "Expose Hermes as an OpenAI-compatible HTTP API for tools like Open WebUI.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", + "env_vars": ( + "API_SERVER_ENABLED", + "API_SERVER_KEY", + "API_SERVER_PORT", + "API_SERVER_HOST", + "API_SERVER_MODEL_NAME", + ), + "required_env": (), + }, + "webhook": { + "name": "Webhooks", + "description": "Receive events from GitHub, GitLab, and other webhook sources.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/", + "env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"), + "required_env": (), + }, +} + +# Display order: well-known platforms surface first; unknown plugins fall to +# the end alphabetically. +_PLATFORM_ORDER: tuple[str, ...] = ( + "telegram", + "discord", + "slack", + "mattermost", + "matrix", + "whatsapp", + "signal", + "bluebubbles", + "homeassistant", + "email", + "sms", + "dingtalk", + "feishu", + "wecom", + "wecom_callback", + "weixin", + "qqbot", + "yuanbao", + "api_server", + "webhook", +) + +# Display labels for env vars not in OPTIONAL_ENV_VARS (HOME_CHANNEL_*, bridge +# toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS +# falls back here so the UI can still render a friendly label. +_MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { + "SIGNAL_HTTP_URL": { + "description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080", + "prompt": "Signal bridge URL", + "url": "https://github.com/bbernhard/signal-cli-rest-api", + }, + "SIGNAL_ACCOUNT": { + "description": "Signal account phone number registered with the bridge", + "prompt": "Signal account", + }, + "SIGNAL_ALLOWED_USERS": { + "description": "Comma-separated Signal users allowed to use the bot", + "prompt": "Allowed Signal users", + }, + "WHATSAPP_ENABLED": { + "description": "Enable the WhatsApp gateway adapter", + "prompt": "Enable WhatsApp", + "advanced": True, + }, + "WHATSAPP_MODE": { + "description": "WhatsApp bridge mode", + "prompt": "WhatsApp mode", + "advanced": True, + }, + "WHATSAPP_ALLOWED_USERS": { + "description": "Comma-separated WhatsApp users allowed to use the bot", + "prompt": "Allowed WhatsApp users", + }, + "HASS_URL": { + "description": "Home Assistant base URL, e.g. https://homeassistant.local:8123", + "prompt": "Home Assistant URL", + }, + "HASS_TOKEN": { + "description": "Long-lived access token from Home Assistant (Profile → Security)", + "prompt": "Home Assistant access token", + "password": True, + }, + "EMAIL_ADDRESS": { + "description": "Email address to send and receive from", + "prompt": "Email address", + }, + "EMAIL_PASSWORD": { + "description": "Email account password or app password", + "prompt": "Email password", + "password": True, + }, + "EMAIL_IMAP_HOST": { + "description": "IMAP server host (e.g. imap.gmail.com)", + "prompt": "IMAP host", + }, + "EMAIL_SMTP_HOST": { + "description": "SMTP server host (e.g. smtp.gmail.com)", + "prompt": "SMTP host", + }, + "TWILIO_ACCOUNT_SID": { + "description": "Twilio Account SID", + "prompt": "Twilio Account SID", + "url": "https://www.twilio.com/console", + }, + "TWILIO_AUTH_TOKEN": { + "description": "Twilio Auth Token", + "prompt": "Twilio Auth Token", + "password": True, + }, + "WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"}, + "WECOM_SECRET": { + "description": "WeCom group bot secret", + "prompt": "WeCom Secret", + "password": True, + }, + "WECOM_CALLBACK_CORP_ID": { + "description": "WeCom corp ID", + "prompt": "WeCom Corp ID", + }, + "WECOM_CALLBACK_CORP_SECRET": { + "description": "WeCom app corp secret", + "prompt": "WeCom Corp Secret", + "password": True, + }, + "WECOM_CALLBACK_AGENT_ID": { + "description": "WeCom app agent ID", + "prompt": "WeCom Agent ID", + }, + "WECOM_CALLBACK_TOKEN": { + "description": "WeCom callback verification token", + "prompt": "WeCom Token", + }, + "WECOM_CALLBACK_ENCODING_AES_KEY": { + "description": "WeCom callback AES encoding key", + "prompt": "WeCom AES Key", + "password": True, + }, + "WEIXIN_ACCOUNT_ID": { + "description": "WeChat Official Account ID", + "prompt": "Account ID", + }, + "WEIXIN_TOKEN": { + "description": "WeChat callback token", + "prompt": "Token", + "password": True, + }, + "WEIXIN_BASE_URL": { + "description": "WeChat platform base URL", + "prompt": "Base URL", + }, + "FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"}, + "FEISHU_APP_SECRET": { + "description": "Feishu / Lark app secret", + "prompt": "App secret", + "password": True, + }, + "FEISHU_ENCRYPT_KEY": { + "description": "Feishu / Lark encrypt key", + "prompt": "Encrypt key", + "password": True, + }, + "FEISHU_VERIFICATION_TOKEN": { + "description": "Feishu / Lark verification token", + "prompt": "Verification token", + "password": True, + }, + "DINGTALK_CLIENT_ID": { + "description": "DingTalk client ID (App key)", + "prompt": "Client ID", + }, + "DINGTALK_CLIENT_SECRET": { + "description": "DingTalk client secret (App secret)", + "prompt": "Client secret", + "password": True, + }, +} + + +def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: + """Build the messaging catalog from the gateway's Platform enum + plugin registry. + + Built-in platforms come from ``gateway.config.Platform`` (LOCAL is excluded). + Plugin platforms come from ``gateway.platform_registry.plugin_entries()``, + which lets newly installed adapters (e.g. IRC) appear without a code change + here. Per-platform UI metadata (description, docs URL, env-var picks) lives + in :data:`_PLATFORM_OVERRIDES`; anything not overridden gets reasonable + defaults derived from the platform id and required_env. + """ + from gateway.config import Platform + + seen: set[str] = set() + entries: list[dict[str, Any]] = [] + + for member in Platform.__members__.values(): + if member.value == "local": + continue + if member.value in seen: + continue + seen.add(member.value) + entries.append(_build_catalog_entry(member.value)) + + try: + from gateway.platform_registry import platform_registry + + for plugin_entry in platform_registry.plugin_entries(): + if plugin_entry.name in seen: + continue + seen.add(plugin_entry.name) + entries.append(_build_catalog_entry(plugin_entry.name, plugin_entry)) + except Exception: + _log.debug("plugin platform registry unavailable", exc_info=True) + + order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)} + entries.sort( + key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower()) + ) + return tuple(entries) + + +def _build_catalog_entry( + platform_id: str, plugin_entry: Any | None = None +) -> dict[str, Any]: + override = _PLATFORM_OVERRIDES.get(platform_id, {}) + + if "env_vars" in override: + env_vars: tuple[str, ...] = tuple(override["env_vars"]) + elif plugin_entry is not None and plugin_entry.required_env: + env_vars = tuple(plugin_entry.required_env) + else: + prefix = platform_id.upper() + "_" + env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix)) + + if "required_env" in override: + required_env = tuple(override["required_env"]) + elif plugin_entry is not None: + required_env = tuple(plugin_entry.required_env or ()) + else: + required_env = () + + if override.get("name"): + name = override["name"] + elif plugin_entry is not None and plugin_entry.label: + name = plugin_entry.label + else: + name = platform_id.replace("_", " ").title() + + description = override.get("description") + if not description and plugin_entry is not None: + description = plugin_entry.install_hint or "" + + return { + "id": platform_id, + "name": name, + "description": description or "", + "docs_url": override.get("docs_url", ""), + "env_vars": env_vars, + "required_env": required_env, + } + + +def _catalog_lookup(platform_id: str) -> dict[str, Any] | None: + for entry in _messaging_platform_catalog(): + if entry["id"] == platform_id: + return entry + return None + + +def _messaging_env_info(key: str) -> dict[str, Any]: + info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {} + return { + "description": info.get("description", ""), + "prompt": info.get("prompt", key), + "url": info.get("url"), + "is_password": info.get("password", False), + "advanced": info.get("advanced", False), + } + + +def _gateway_platform_config(platform_id: str): + from gateway.config import Platform, load_gateway_config + + config = load_gateway_config() + platform = Platform(platform_id) + platform_config = config.platforms.get(platform) + return config, platform, platform_config + + +def _messaging_platform_payload( + entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None +) -> dict[str, Any]: + platform_id = entry["id"] + gateway_running = get_running_pid() is not None + runtime_platforms = runtime.get("platforms") if runtime else {} + runtime_platform = ( + runtime_platforms.get(platform_id, {}) + if isinstance(runtime_platforms, dict) + else {} + ) + env_vars = [] + + for key in entry["env_vars"]: + value = env_on_disk.get(key) or os.getenv(key, "") + env_vars.append( + { + "key": key, + "required": key in entry["required_env"], + "is_set": bool(value), + "redacted_value": redact_key(value) if value else None, + **_messaging_env_info(key), + } + ) + + try: + gateway_config, platform, platform_config = _gateway_platform_config( + platform_id + ) + enabled = bool(platform_config and platform_config.enabled) + configured = bool( + platform_config + and gateway_config._is_platform_connected(platform, platform_config) + ) + home_channel = ( + platform_config.home_channel.to_dict() + if platform_config and platform_config.home_channel + else None + ) + except Exception: + enabled = False + configured = all( + env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"] + ) + home_channel = None + + state = ( + runtime_platform.get("state") if isinstance(runtime_platform, dict) else None + ) + if not enabled: + state = "disabled" + elif not configured: + state = "not_configured" + elif gateway_running and not state: + state = "pending_restart" + elif not gateway_running and not state: + state = "gateway_stopped" + + return { + "id": platform_id, + "name": entry["name"], + "description": entry["description"], + "docs_url": entry["docs_url"], + "enabled": enabled, + "configured": configured, + "gateway_running": gateway_running, + "state": state, + "error_code": ( + runtime_platform.get("error_code") + if isinstance(runtime_platform, dict) + else None + ), + "error_message": ( + runtime_platform.get("error_message") + if isinstance(runtime_platform, dict) + else None + ), + "updated_at": ( + runtime_platform.get("updated_at") + if isinstance(runtime_platform, dict) + else None + ), + "home_channel": home_channel, + "env_vars": env_vars, + } + + +def _write_platform_enabled(platform_id: str, enabled: bool) -> None: + config = load_config() + platforms = config.setdefault("platforms", {}) + if not isinstance(platforms, dict): + platforms = {} + config["platforms"] = platforms + platform_config = platforms.setdefault(platform_id, {}) + if not isinstance(platform_config, dict): + platform_config = {} + platforms[platform_id] = platform_config + platform_config["enabled"] = enabled + save_config(config) + + +@app.get("/api/messaging/platforms") +async def get_messaging_platforms(): + env_on_disk = load_env() + runtime = read_runtime_status() + return { + "platforms": [ + _messaging_platform_payload(entry, env_on_disk, runtime) + for entry in _messaging_platform_catalog() + ] + } + + +@app.put("/api/messaging/platforms/{platform_id}") +async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate): + entry = _catalog_lookup(platform_id) + if not entry: + raise HTTPException( + status_code=404, detail=f"Unknown messaging platform: {platform_id}" + ) + + allowed_env = set(entry["env_vars"]) + try: + for key in body.clear_env: + if key not in allowed_env: + raise HTTPException( + status_code=400, + detail=f"{key} is not configurable for {entry['name']}", + ) + remove_env_value(key) + + for key, value in body.env.items(): + if key not in allowed_env: + raise HTTPException( + status_code=400, + detail=f"{key} is not configurable for {entry['name']}", + ) + trimmed = value.strip() + if trimmed: + save_env_value(key, trimmed) + + if body.enabled is not None: + _write_platform_enabled(platform_id, body.enabled) + + return {"ok": True, "platform": platform_id} + except HTTPException: + raise + except Exception: + _log.exception("PUT /api/messaging/platforms/%s failed", platform_id) + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.post("/api/messaging/platforms/{platform_id}/test") +async def test_messaging_platform(platform_id: str): + entry = _catalog_lookup(platform_id) + if not entry: + raise HTTPException( + status_code=404, detail=f"Unknown messaging platform: {platform_id}" + ) + + env_on_disk = load_env() + payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status()) + if not payload["enabled"]: + message = f"{entry['name']} is disabled. Enable it, then restart the gateway." + return {"ok": False, "state": payload["state"], "message": message} + if not payload["configured"]: + missing = [ + field["key"] + for field in payload["env_vars"] + if field["required"] and not field["is_set"] + ] + message = ( + f"Missing required setup: {', '.join(missing)}" + if missing + else "Platform setup is incomplete." + ) + return {"ok": False, "state": payload["state"], "message": message} + if not payload["gateway_running"]: + return { + "ok": False, + "state": payload["state"], + "message": "Gateway is not running. Restart the gateway to connect this platform.", + } + if payload["state"] == "connected": + return { + "ok": True, + "state": payload["state"], + "message": f"{entry['name']} is connected.", + } + if payload.get("error_message"): + return { + "ok": False, + "state": payload["state"], + "message": payload["error_message"], + } + return { + "ok": False, + "state": payload["state"], + "message": "Setup looks complete, but the gateway has not reported a connection yet. Restart the gateway.", + } + + # --------------------------------------------------------------------------- # OAuth provider endpoints — status + disconnect (Phase 1) # --------------------------------------------------------------------------- @@ -3133,6 +4235,123 @@ async def get_toolsets(): return result +class ToolsetToggle(BaseModel): + enabled: bool + + +@app.put("/api/tools/toolsets/{name}") +async def toggle_toolset(name: str, body: ToolsetToggle): + """Enable/disable a configurable toolset for the desktop (cli) platform. + + Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools`` + helper the CLI ``hermes tools`` picker uses, so the GUI and CLI stay in + lockstep. Returns 400 for unknown toolset keys. + """ + from hermes_cli.tools_config import ( + _get_effective_configurable_toolsets, + _get_platform_tools, + _save_platform_tools, + ) + + valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()} + if name not in valid: + raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") + + config = load_config() + enabled = set( + _get_platform_tools(config, "cli", include_default_mcp_servers=False) + ) + if body.enabled: + enabled.add(name) + else: + enabled.discard(name) + _save_platform_tools(config, "cli", enabled) + return {"ok": True, "name": name, "enabled": body.enabled} + + +@app.get("/api/tools/toolsets/{name}/config") +async def get_toolset_config(name: str): + """Return the provider matrix + key status for a toolset's config panel. + + Surfaces the same provider rows the CLI ``hermes tools`` picker shows + (via ``_visible_providers``), each with its ``env_vars`` annotated with + current ``is_set`` state so the GUI can render provider selection + key + entry. Toolsets without a ``TOOL_CATEGORIES`` entry return an empty + provider list and ``has_category: false``. Returns 400 for unknown keys. + """ + from hermes_cli.tools_config import ( + TOOL_CATEGORIES, + _get_effective_configurable_toolsets, + _visible_providers, + ) + from hermes_cli.config import get_env_value + + valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()} + if name not in valid: + raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") + + config = load_config() + cat = TOOL_CATEGORIES.get(name) + providers = [] + if cat: + for prov in _visible_providers(cat, config, force_fresh=True): + env_vars = [ + { + "key": e["key"], + "prompt": e.get("prompt", e["key"]), + "url": e.get("url"), + "default": e.get("default"), + "is_set": bool(get_env_value(e["key"])), + } + for e in prov.get("env_vars", []) + ] + providers.append({ + "name": prov["name"], + "badge": prov.get("badge", ""), + "tag": prov.get("tag", ""), + "env_vars": env_vars, + "post_setup": prov.get("post_setup"), + "requires_nous_auth": bool(prov.get("requires_nous_auth")), + }) + return { + "name": name, + "has_category": cat is not None, + "providers": providers, + } + + +class ToolsetProviderSelect(BaseModel): + provider: str + + +@app.put("/api/tools/toolsets/{name}/provider") +async def select_toolset_provider(name: str, body: ToolsetProviderSelect): + """Persist a provider selection for a toolset (no key prompting). + + Delegates to ``apply_provider_selection`` — the shared, non-interactive + core extracted from the CLI configurator — so the GUI and ``hermes tools`` + write identical config keys (``web.backend``, ``tts.provider``, etc.). + API keys and post-setup flows are handled by separate endpoints. Returns + 400 for unknown toolset or provider names. + """ + from hermes_cli.tools_config import ( + apply_provider_selection, + _get_effective_configurable_toolsets, + ) + + valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()} + if name not in valid: + raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") + + config = load_config() + try: + apply_provider_selection(name, body.provider, config) + except KeyError as exc: + raise HTTPException(status_code=400, detail=str(exc).strip('"')) + save_config(config) + return {"ok": True, "name": name, "provider": body.provider} + + # --------------------------------------------------------------------------- # Raw YAML config endpoint # --------------------------------------------------------------------------- @@ -3430,7 +4649,16 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: return True parsed = urllib.parse.urlparse(origin) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: + if parsed.scheme not in {"http", "https"}: + # Packaged Electron loads the desktop renderer over file://, so its + # WebSocket handshake carries a non-web Origin such as file:// or null. + # DNS-rebinding attacks originate from an http(s) site; they cannot + # forge a file:// origin and still hold the loopback session token. + # Public/gated binds have no legitimate non-web client, so keep + # rejecting these origins there. + return bound_host.lower() in _LOOPBACK_HOST_VALUES + + if not parsed.netloc: return False return _is_accepted_host(parsed.netloc, bound_host) @@ -3507,6 +4735,10 @@ def _resolve_chat_argv( Appending ``--resume <id>`` to argv doesn't work because ``ui-tui`` does not parse its argv. + ``HERMES_TUI_GATEWAY_URL`` is injected so the PTY child can attach to + this process's in-memory ``tui_gateway`` instance instead of spawning + its own Python gateway subprocess. + `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`). @@ -3534,9 +4766,30 @@ def _resolve_chat_argv( if sidecar_url: env["HERMES_TUI_SIDECAR_URL"] = sidecar_url + if gateway_ws_url := _build_gateway_ws_url(): + env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url + return list(argv), str(cwd) if cwd else None, env +def _build_gateway_ws_url() -> Optional[str]: + """ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.""" + 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}) + + return f"ws://{netloc}/api/ws?{qs}" + + def _build_sidecar_url(channel: str) -> Optional[str]: """ws:// URL the PTY child should publish events to, or None when unbound. @@ -3927,6 +5180,17 @@ def mount_spa(application: FastAPI): @application.get("/{full_path:path}") async def serve_spa(full_path: str, request: Request): prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) + # An unmatched /api/* path is a missing/renamed endpoint, NOT a + # client-side route. Falling through to index.html here returns + # `<!doctype html>` with status 200, which makes JSON clients (the + # desktop app's fetchJson, dashboard fetch wrappers) blow up with an + # opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON + # so the caller sees a clear "no such endpoint" instead. + if full_path == "api" or full_path.startswith("api/"): + return JSONResponse( + {"detail": f"No such API endpoint: /{full_path}"}, + status_code=404, + ) file_path = WEB_DIST / full_path # Prevent path traversal via url-encoded sequences (%2e%2e/) if ( diff --git a/hermes_logging.py b/hermes_logging.py index b7d9c9e55..a3656c8c1 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -8,6 +8,8 @@ Log files produced: agent.log — INFO+, all agent/tool/session activity (the main log) errors.log — WARNING+, errors and warnings only (quick triage) gateway.log — INFO+, gateway-only events (created when mode="gateway") + gui.log — INFO+, dashboard/websocket/TUI-gateway events + (created when mode="gui") All files use ``RotatingFileHandler`` with ``RedactingFormatter`` so secrets are never written to disk. @@ -15,6 +17,8 @@ secrets are never written to disk. Component separation: gateway.log only receives records from ``gateway.*`` loggers — platform adapters, session management, slash commands, delivery. + gui.log receives dashboard-side records from ``hermes_cli.web_server``, + ``hermes_cli.pty_bridge``, ``tui_gateway.*``, and ``uvicorn.*``. agent.log remains the catch-all (everything goes there). Session context: @@ -146,6 +150,12 @@ COMPONENT_PREFIXES = { "tools": ("tools",), "cli": ("hermes_cli", "cli"), "cron": ("cron",), + "gui": ( + "hermes_cli.web_server", + "hermes_cli.pty_bridge", + "tui_gateway", + "uvicorn", + ), } @@ -183,9 +193,11 @@ def setup_logging( Number of rotated backup files to keep. Defaults to 3 or the value from config.yaml ``logging.backup_count``. mode - Caller context: ``"cli"``, ``"gateway"``, ``"cron"``. + Caller context: ``"cli"``, ``"gateway"``, ``"gui"``, ``"cron"``. When ``"gateway"``, an additional ``gateway.log`` file is created that receives only gateway-component records. + When ``"gui"``, an additional ``gui.log`` file is created that + receives dashboard and TUI-gateway component records. force Re-run setup even if it has already been called. @@ -244,6 +256,18 @@ def setup_logging( log_filter=_ComponentFilter(COMPONENT_PREFIXES["gateway"]), ) + # --- gui.log (INFO+, dashboard/tui-gateway components) ----------------- + if mode == "gui": + _add_rotating_handler( + root, + log_dir / "gui.log", + level=logging.INFO, + max_bytes=10 * 1024 * 1024, + backup_count=5, + formatter=RedactingFormatter(_LOG_FORMAT), + log_filter=_ComponentFilter(COMPONENT_PREFIXES["gui"]), + ) + if _logging_initialized and not force: return log_dir diff --git a/hermes_state.py b/hermes_state.py index 5122c69b9..ca802994a 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -249,6 +249,7 @@ CREATE TABLE IF NOT EXISTS sessions ( cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0, + cwd TEXT, billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, @@ -890,13 +891,14 @@ class SessionDB: system_prompt: str = None, user_id: str = None, parent_session_id: str = None, + cwd: str = None, ) -> None: """Shared INSERT OR IGNORE for session rows.""" def _do(conn): conn.execute( """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config, - system_prompt, parent_session_id, started_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + system_prompt, parent_session_id, cwd, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, source, @@ -905,6 +907,7 @@ class SessionDB: json.dumps(model_config) if model_config else None, system_prompt, parent_session_id, + cwd, time.time(), ), ) @@ -941,6 +944,15 @@ class SessionDB: ) self._execute_write(_do) + def update_session_cwd(self, session_id: str, cwd: str) -> None: + """Persist the session working directory when a frontend knows it.""" + if not session_id or not cwd: + return + + def _do(conn): + conn.execute("UPDATE sessions SET cwd = ? WHERE id = ?", (cwd, session_id)) + + self._execute_write(_do) # ────────────────────────────────────────────────────────────────────── # Compression locks # ────────────────────────────────────────────────────────────────────── @@ -1507,6 +1519,7 @@ class SessionDB: limit: int = 20, offset: int = 0, include_children: bool = False, + min_message_count: int = 0, project_compression_tips: bool = True, order_by_last_active: bool = False, ) -> List[Dict[str, Any]]: @@ -1561,6 +1574,9 @@ class SessionDB: placeholders = ",".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(exclude_sources) + if min_message_count > 0: + where_clauses.append("s.message_count >= ?") + params.append(min_message_count) where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" if order_by_last_active: @@ -1680,7 +1696,7 @@ class SessionDB: for key in ( "id", "ended_at", "end_reason", "message_count", "tool_call_count", "title", "last_active", "preview", - "model", "system_prompt", + "model", "system_prompt", "cwd", ): if key in tip_row: merged[key] = tip_row[key] @@ -2785,15 +2801,22 @@ class SessionDB: # Utility # ========================================================================= - def session_count(self, source: str = None) -> int: + def session_count(self, source: str = None, min_message_count: int = 0) -> int: """Count sessions, optionally filtered by source.""" + where_clauses = [] + params = [] + + if source: + where_clauses.append("source = ?") + params.append(source) + if min_message_count > 0: + where_clauses.append("message_count >= ?") + params.append(min_message_count) + + where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + with self._lock: - if source: - cursor = self._conn.execute( - "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,) - ) - else: - cursor = self._conn.execute("SELECT COUNT(*) FROM sessions") + cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions{where_sql}", params) return cursor.fetchone()[0] def message_count(self, session_id: str = None) -> int: diff --git a/nix/desktop.nix b/nix/desktop.nix new file mode 100644 index 000000000..bbe0462d7 --- /dev/null +++ b/nix/desktop.nix @@ -0,0 +1,116 @@ +# nix/desktop.nix — Hermes Desktop (Electron) app build + wrapper +# +# `hermesAgent` is the fully-built `.#default` package — it ships the +# `hermes` binary with the venv, runtime PATH, bundled skills/plugins, etc. +# already wired up. We point the desktop at it via the existing +# `HERMES_DESKTOP_HERMES` override env var, so the desktop's resolver +# uses our fully wrapped binary at step 4 ("existing Hermes CLI"). +# No reimplementation of the agent resolution in this wrapper. +{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }: +let + src = ../apps; + npmDeps = pkgs.fetchNpmDeps { + src = ../apps/desktop; + # buildNpmPackage uses `npm ci` which is strict — peer deps not in the + # lockfile cause network fetch attempts. Fetcher v2 stages the full + # cache (including peer-only deps) so `npm ci` can resolve them offline. + fetcherVersion = 2; + hash = "sha256-7W9ObYz08yDMtybY8+RkUXkKVsJXINLl0qBUB91hpao="; + }; + + npm = hermesNpmLib.mkNpmPassthru { folder = "apps/desktop"; attr = "desktop"; pname = "hermes-desktop"; }; + + packageJson = builtins.fromJSON (builtins.readFile (src + "/desktop/package.json")); + version = packageJson.version; + + # Build the renderer (dist/ + electron/ + package.json). + renderer = pkgs.buildNpmPackage (npm // { + pname = "hermes-desktop-renderer"; + inherit src npmDeps version; + sourceRoot = "apps/desktop"; + + doCheck = false; + # buildNpmPackage uses `npm ci` which fails on peer deps not in the + # lockfile. npmDepsFetcherVersion=2 stages the full cache (peer deps + # included) so the offline `npm ci` resolves them. + npmDepsFetcherVersion = 2; + # `--ignore-scripts` skips the electron prebuild download (we use nixpkgs + # electron instead). `--legacy-peer-deps` matches the dev workflow — + # apps/desktop has conflicting peer deps (zod, @testing-library) that + # the package.json relies on npm 7+ to relax. + npmFlags = [ "--ignore-scripts" "--legacy-peer-deps" ]; + makeCacheWritable = true; + + buildPhase = '' + runHook preBuild + + # write-build-stamp.cjs replacement. Packaged Electron reads this + # at first-launch to pin the install.ps1 git ref; informational in + # nix builds (the backend comes from the derivation directly). + mkdir -p build + echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > build/install-stamp.json + + # The vite config aliases react/react-dom to ../../node_modules/react + # (workspace root, where npm dedups them in dev). In the standalone + # nix build there is no workspace root, so the deps are installed + # locally — rewrite the aliases to point at the local copy. + substituteInPlace vite.config.ts \ + --replace-quiet '../../node_modules/' './node_modules/' + + # vite handles TS transpilation via esbuild — no type-checking. + # We skip `tsc -b` to avoid type errors in test files that don't + # ship in the bundle (real upstream peer-dep version mismatches + # in @testing-library/react v16 — not blocking the build). + npx vite build --outDir dist + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r dist electron build $out/ + cp package.json $out/ + runHook postInstall + ''; + }); +in + +# Electron wrapper: nixpkgs' electron binary pointed at the renderer dir. +stdenv.mkDerivation { + pname = "hermes-desktop"; + inherit version; + + dontUnpack = true; + dontBuild = true; + + nativeBuildInputs = [ makeWrapper ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/hermes-desktop $out/bin + cp -r ${renderer}/* $out/share/hermes-desktop/ + + # Wrap the nixpkgs electron binary to launch our app. Set + # HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes` + # binary so the desktop's resolver step 4 ("existing Hermes CLI on + # PATH") uses our fully wrapped binary — venv with all deps, + # bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc). + # No reimplementation of the agent resolver in the wrapper. + makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \ + --add-flags "$out/share/hermes-desktop" \ + --set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \ + --set ELECTRON_IS_DEV 0 + + runHook postInstall + ''; + + meta = with lib; { + description = "Native Electron desktop shell for Hermes Agent"; + homepage = "https://github.com/NousResearch/hermes-agent"; + license = licenses.mit; + platforms = platforms.unix; + mainProgram = "hermes-desktop"; + }; +} diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index f373c25bc..073e652f8 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -11,6 +11,7 @@ callPackage, python312, nodejs_22, + electron, ripgrep, git, openssh, @@ -136,7 +137,7 @@ let print('No collisions found.') ''; in -stdenv.mkDerivation { +stdenv.mkDerivation (finalAttrs: { pname = "hermes-agent"; version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version; @@ -192,6 +193,18 @@ stdenv.mkDerivation { hermesVenv ; + # `hermesDesktop` references `finalAttrs.finalPackage` (this whole + # derivation, after all overrides are applied) so the desktop wrapper + # can prepend its `/bin` to PATH. The desktop's resolver step 4 + # ("existing hermes on PATH") then picks up the fully wrapped + # `hermes` binary — venv with all deps, bundled skills/plugins, + # runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation + # of the agent resolution in the desktop wrapper. + hermesDesktop = callPackage ./desktop.nix { + inherit hermesNpmLib electron; + hermesAgent = finalAttrs.finalPackage; + }; + devShellHook = '' STAMP=".nix-stamps/hermes-agent" STAMP_VALUE="${pyprojectHash}:${uvLockHash}" @@ -217,4 +230,4 @@ stdenv.mkDerivation { license = licenses.mit; platforms = platforms.unix; }; -} +}) diff --git a/nix/packages.nix b/nix/packages.nix index 729ee5837..cf4ec8012 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -49,9 +49,10 @@ tui = hermesAgent.hermesTui; web = hermesAgent.hermesWeb; + desktop = hermesAgent.hermesDesktop; fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { - packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb ]; + packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb hermesAgent.hermesDesktop ]; }; }; }; diff --git a/package-lock.json b/package-lock.json index 055fb0c9b..093d6353a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,9320 @@ "version": "1.0.0", "hasInstallScript": true, "license": "MIT", + "workspaces": [ + "apps/*" + ], "dependencies": { + "@streamdown/math": "^1.0.2", "agent-browser": "^0.26.0" }, "engines": { "node": ">=20.0.0" } }, + "apps/bootstrap-installer": { + "name": "@hermes/bootstrap-installer", + "version": "0.0.1", + "dependencies": { + "@nous-research/ui": "0.16.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.1", + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-process": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "@vscode/codicons": "^0.0.45", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "katex": "^0.16.45", + "lucide-react": "^0.577.0", + "nanostores": "^1.3.0", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-shimmer": "^0.4.11" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "~5.9.3", + "vite": "^7.3.1" + } + }, + "apps/desktop": { + "name": "hermes", + "version": "0.0.2", + "dependencies": { + "@assistant-ui/react": "^0.12.28", + "@assistant-ui/react-streamdown": "^0.1.11", + "@audiowave/react": "^0.6.2", + "@chenglou/pretext": "^0.0.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hermes/shared": "file:../shared", + "@nanostores/react": "^1.1.0", + "@nous-research/ui": "^0.13.0", + "@radix-ui/react-slot": "^1.2.4", + "@streamdown/code": "^1.1.1", + "@tabler/icons-react": "^3.41.1", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query": "^5.100.6", + "@tanstack/react-virtual": "^3.13.24", + "@vscode/codicons": "^0.0.45", + "@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", + "cmdk": "^1.1.1", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.2", + "ignore": "^7.0.5", + "katex": "^0.16.45", + "leva": "^0.10.1", + "motion": "^12.38.0", + "nanostores": "^1.3.0", + "node-pty": "1.1.0", + "radix-ui": "^1.4.3", + "react": "^19.2.5", + "react-arborist": "^3.5.0", + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2", + "react-shiki": "^0.9.3", + "remark-math": "^6.0.0", + "shiki": "^4.0.2", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", + "tw-shimmer": "^0.4.11", + "unicode-animations": "^1.0.3", + "unified": "^11.0.5", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3", + "web-haptics": "^0.0.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.3.2", + "@types/hast": "^3.0.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "electron": "^40.9.3", + "electron-builder": "^26.8.1", + "eslint": "^9.39.4", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-compiler": "^19.1.0-rc.2", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^16.5.0", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", + "rcedit": "^5.0.2", + "typescript": "^6.0.3", + "vite": "^8.0.10", + "vitest": "^4.1.5", + "wait-on": "^9.0.5" + } + }, + "apps/desktop/node_modules/@nous-research/ui": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz", + "integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==", + "dependencies": { + "@nanostores/react": "^1.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "nanostores": "^1.0.1", + "sanitize-html": "^2.16.0", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0", + "unicode-animations": "^1.0.3" + }, + "peerDependencies": { + "@observablehq/plot": "^0.6.17", + "@react-three/fiber": "^9.4.0", + "gsap": "^3.13.0", + "leva": "^0.10.1", + "motion": "^12.38.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "three": "^0.180.0" + }, + "peerDependenciesMeta": { + "@observablehq/plot": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "gsap": { + "optional": true + }, + "leva": { + "optional": true + }, + "three": { + "optional": true + } + } + }, + "apps/desktop/node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "apps/desktop/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "apps/desktop/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "apps/desktop/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "apps/desktop/node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "apps/shared": { + "name": "@hermes/shared", + "version": "0.0.0", + "devDependencies": { + "typescript": "^6.0.3" + } + }, + "apps/shared/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@assistant-ui/core": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@assistant-ui/core/-/core-0.1.17.tgz", + "integrity": "sha512-IWIP98UVQ9W+oF0yz8XqFRtaX8HtozWVUWt6D/BSV6cyKwLfJ8niHtLG74bSnllTnGcreU2El3GR/tIodR1XuA==", + "license": "MIT", + "dependencies": { + "assistant-stream": "^0.3.12", + "nanoid": "^5.1.9" + }, + "peerDependencies": { + "@assistant-ui/store": "^0.2.9", + "@assistant-ui/tap": "^0.5.10", + "@types/react": "*", + "assistant-cloud": "^0.1.27", + "react": "^18 || ^19", + "zustand": "^5.0.11" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "assistant-cloud": { + "optional": true + }, + "react": { + "optional": true + }, + "zustand": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react": { + "version": "0.12.28", + "resolved": "https://registry.npmjs.org/@assistant-ui/react/-/react-0.12.28.tgz", + "integrity": "sha512-czjpexLK1lKnNDNM1YMJi8SufeKUWBICqiVUtiHMV+86PYGCwJykOZKkchI8MVbSQ62xZ8A1LfPO5W2IDjed3A==", + "license": "MIT", + "dependencies": { + "@assistant-ui/core": "^0.1.17", + "@assistant-ui/store": "^0.2.9", + "@assistant-ui/tap": "^0.5.10", + "@radix-ui/primitive": "^1.1.3", + "@radix-ui/react-compose-refs": "^1.1.2", + "@radix-ui/react-context": "^1.1.3", + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-use-callback-ref": "^1.1.1", + "@radix-ui/react-use-escape-keydown": "^1.1.1", + "assistant-cloud": "^0.1.27", + "assistant-stream": "^0.3.12", + "nanoid": "^5.1.9", + "radix-ui": "^1.4.3", + "react-textarea-autosize": "^8.5.9", + "zod": "^4.3.6", + "zustand": "^5.0.12" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react-streamdown": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@assistant-ui/react-streamdown/-/react-streamdown-0.1.11.tgz", + "integrity": "sha512-9y+89ZxotYSt81hChSVjK2kwUYRKq7UW/r5qoqZTpcb7119gc0NOj0dx9xxuyXE2QfR6EY8rW6yBz3g+Y7RrhQ==", + "license": "MIT", + "dependencies": { + "rehype-harden": "^1.1.8", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "streamdown": "^2.5.0" + }, + "peerDependencies": { + "@assistant-ui/react": "^0.12.26", + "@streamdown/cjk": "^1.0.0", + "@streamdown/code": "^1.0.0", + "@streamdown/math": "^1.0.0", + "@streamdown/mermaid": "^1.0.0", + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@streamdown/cjk": { + "optional": true + }, + "@streamdown/code": { + "optional": true + }, + "@streamdown/math": { + "optional": true + }, + "@streamdown/mermaid": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/store": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.9.tgz", + "integrity": "sha512-EDd6yCfirb2OsAKoTo7HeMtqPG+1cqVlNXOzUsho35ZF3O1XQ2CyEY4iUbdhj3HfmWeZo7rmfhvbaYQVEqAfeA==", + "license": "MIT", + "dependencies": { + "use-effect-event": "^2.0.3" + }, + "peerDependencies": { + "@assistant-ui/tap": "^0.5.10", + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/tap": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@assistant-ui/tap/-/tap-0.5.10.tgz", + "integrity": "sha512-sBHTf+q1geRyu5l4gJJp2hk6ZxwhHZHj39ixjC9ARADuIYedYv1B8bCNS82eTC/COpD1xe86mzvT/+HwIsO9WA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@audiowave/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@audiowave/core/-/core-0.3.1.tgz", + "integrity": "sha512-KtC2MTWKp6Orkedty3I8IklVBVQ2IFaFWDJ1cz+UsACpX2x1gINwZGTRZT7bw/dx8KazNSMuVK5lm1jL67KQkQ==", + "license": "MIT" + }, + "node_modules/@audiowave/react": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@audiowave/react/-/react-0.6.2.tgz", + "integrity": "sha512-hajG2Iv3mVxived9wXad8L0ZQF+HmYnB3IrfOkIdkTv4RxOJDXwFWMAd0zb7ZU1Qz0IEYZXCbASFWyuxEQ7PAw==", + "license": "MIT", + "dependencies": { + "@audiowave/core": "0.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", + "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.29.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "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.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@chenglou/pretext": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.6.tgz", + "integrity": "sha512-U10s4tFeyu3oVHfXuNWwZSKqHXefhaigpcBkGj60qQFRJ+yUoQ+ez3cGJelP7BWDAB58HCgjcTSmOcg+77afBQ==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "license": "Apache-2.0" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/asar/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/rebuild": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/rebuild/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@electron/rebuild/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.29.0.tgz", + "integrity": "sha512-bGc7hHz6lrdpMqH3XqfiHc5PKzEhjgUj6OLpTXynkLi9JZKyMByI/tdpm4Liu6O2BjtE1lakBWXjOQS1EnSQLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hermes/bootstrap-installer": { + "resolved": "apps/bootstrap-installer", + "link": true + }, + "node_modules/@hermes/shared": { + "resolved": "apps/shared", + "link": true + }, + "node_modules/@humanfs/core": { + "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.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.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", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.1.tgz", + "integrity": "sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.2" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, + "node_modules/@nanostores/react": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.1.0.tgz", + "integrity": "sha512-MbH35fjhcf7LAubYX5vhOChYUfTLzNLqH/mBGLVsHkcvjy0F8crO1WQwdmQ2xKbAmtpalDa2zBt3Hlg5kqr8iw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "nanostores": "^1.2.0", + "react": ">=18.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nous-research/ui": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.16.0.tgz", + "integrity": "sha512-JvSwf9vBOCEEGDSOYIRn/F/JJSBDh9DvGU3s3OFbX6K1otnSK7s47cZdgvfBoEPmeKFom2fWQDDqfzLV+eR7Qg==", + "license": "MIT", + "dependencies": { + "@nanostores/react": "^1.1.0", + "@radix-ui/react-checkbox": "^1.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "nanostores": "^1.3.0", + "sanitize-html": "^2.17.4", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "unicode-animations": "^1.0.3" + }, + "peerDependencies": { + "@observablehq/plot": "^0.6.17", + "@react-three/fiber": "^9.4.0", + "gsap": "^3.13.0", + "leva": "^0.10.1", + "motion": "^12.38.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "three": "^0.180.0" + }, + "peerDependenciesMeta": { + "@observablehq/plot": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "gsap": { + "optional": true + }, + "leva": { + "optional": true + }, + "three": { + "optional": true + } + } + }, + "node_modules/@observablehq/plot": { + "version": "0.6.17", + "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", + "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", + "license": "ISC", + "dependencies": { + "d3": "^7.9.0", + "interval-tree-1d": "^1.0.0", + "isoformat": "^0.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-dnd/asap": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==" + }, + "node_modules/@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" + }, + "node_modules/@react-three/fiber": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@stitches/react": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz", + "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.3.0" + } + }, + "node_modules/@streamdown/code": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz", + "integrity": "sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==", + "license": "Apache-2.0", + "dependencies": { + "shiki": "^3.19.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@streamdown/code/node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@streamdown/code/node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@streamdown/math": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@streamdown/math/-/math-1.0.2.tgz", + "integrity": "sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g==", + "license": "Apache-2.0", + "dependencies": { + "katex": "^0.16.27", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tabler/icons": { + "version": "3.41.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz", + "integrity": "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.41.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.41.1.tgz", + "integrity": "sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.41.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tailwindcss/node": { + "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.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "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.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.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "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", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "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.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.8.tgz", + "integrity": "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.8.tgz", + "integrity": "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "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", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vscode/codicons": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45.tgz", + "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/agent-browser": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.26.0.tgz", @@ -25,6 +9332,13195 @@ "bin": { "agent-browser": "bin/agent-browser.js" } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.8.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assistant-cloud": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz", + "integrity": "sha512-BGfVnx7YFN5xtB/kbrgGxRI0TfSWq4yxB3MwYn6RDPlv4JvdtPupvDC1Y6An0EhAe42Z0AYtSmDSsR6p6eeBng==", + "license": "MIT", + "dependencies": { + "assistant-stream": "^0.3.12" + } + }, + "node_modules/assistant-stream": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.3.12.tgz", + "integrity": "sha512-ZdfdyeZjeffkUfZLGTre9rW+9nBSPi6U5tYvchYjAxVuyiYVf5H9vw7SxegTq5bAMT9IitpDOaYMZGWFoMtaow==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "nanoid": "^5.1.9", + "secure-json-parse": "^4.1.0" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "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" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util-runtime/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/builder-util-runtime/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/builder-util/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chevrotain": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz", + "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "chevrotain": "^12.0.0" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn-windows-exe": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cross-spawn-windows-exe/-/cross-spawn-windows-exe-1.2.0.tgz", + "integrity": "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-cross-spawn-windows-exe?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@malept/cross-spawn-promise": "^1.1.0", + "is-wsl": "^2.2.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn-windows-exe/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", + "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "40.9.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.9.3.tgz", + "integrity": "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.8.1", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", + "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "license": "ISC" + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "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.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@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": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.9.0.tgz", + "integrity": "sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.58.2", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-compiler": { + "version": "19.1.0-rc.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz", + "integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "hermes-parser": "^0.25.1", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + }, + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-plugin-react-compiler/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/eslint-plugin-react-compiler/node_modules/zod-validation-error": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz", + "integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.24.4" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "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": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "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 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-selector": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz", + "integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fingerprint-generator": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.82.tgz", + "integrity": "sha512-5Z/yCKW324pMyMarpIKe/QPdkrFWKNJv3ktdU+fXHri80+HAwNE6QhMvEvsMkK9Q8DeCXZlpPHV77UBa1nFb4A==", + "license": "Apache-2.0", + "dependencies": { + "generative-bayesian-network": "^2.1.82", + "header-generator": "^2.1.82", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generative-bayesian-network": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.82.tgz", + "integrity": "sha512-DH4NrmQheoMaJErdVv2IzaqkbOYSDQZmiZTV6UPDJYRDK2EyPpIQ88XRcYdPeFrUjS1N0Jj25H3HUywoJ1dbow==", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.9", + "tslib": "^2.4.0" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hast-util-raw/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/header-generator": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.82.tgz", + "integrity": "sha512-4NjPB0+bAKjPoponSmTOkK58IEF2W22sOJA5O48k/MxbCZgOm+jrU4WVR53Z2I6xFgIPkVrQmKtt1LAbWtfqXw==", + "license": "Apache-2.0", + "dependencies": { + "browserslist": "^4.21.1", + "generative-bayesian-network": "^2.1.82", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/hermes": { + "resolved": "apps/desktop", + "link": true + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/impit": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit/-/impit-0.7.6.tgz", + "integrity": "sha512-AkS6Gv63+E6GMvBrcRhMmOREKpq5oJ0J5m3xwfkHiEs97UIsbpEqFmW3sFw/sdyOTDGRF5q4EjaLxtb922Ta8g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "impit-darwin-arm64": "0.7.6", + "impit-darwin-x64": "0.7.6", + "impit-linux-arm64-gnu": "0.7.6", + "impit-linux-arm64-musl": "0.7.6", + "impit-linux-x64-gnu": "0.7.6", + "impit-linux-x64-musl": "0.7.6", + "impit-win32-arm64-msvc": "0.7.6", + "impit-win32-x64-msvc": "0.7.6" + } + }, + "node_modules/impit-darwin-arm64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.7.6.tgz", + "integrity": "sha512-M7NQXkttyzqilWfzVkNCp7hApT69m0etyJkVpHze4bR5z1kJnHhdsb8BSdDv2dzvZL4u1JyqZNxq+qoMn84eUw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-darwin-x64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.7.6.tgz", + "integrity": "sha512-kikTesWirAwJp9JPxzGLoGVc+heBlEabWS5AhTkQedACU153vmuL90OBQikVr3ul2N0LPImvnuB+51wV0zDE6g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.7.6.tgz", + "integrity": "sha512-H6GHjVr/0lG9VEJr6IHF8YLq+YkSIOF4k7Dfue2ygzUAj1+jZ5ZwnouhG/XrZHYW6EWsZmEAjjRfWE56Q0wDRQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.7.6.tgz", + "integrity": "sha512-1sCB/UBVXLZTpGJsXRdNNSvhN9xmmQcYLMWAAB4Itb7w684RHX1pLoCb6ichv7bfAf6tgaupcFIFZNBp3ghmQA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.7.6.tgz", + "integrity": "sha512-yYhlRnZ4fhKt8kuGe0JK2WSHc8TkR6BEH0wn+guevmu8EOn9Xu43OuRvkeOyVAkRqvFnlZtMyySUo/GuSLz9Gw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.7.6.tgz", + "integrity": "sha512-sdGWyu+PCLmaOXy7Mzo4WP61ZLl5qpZ1L+VeXW+Ycazgu0e7ox0NZLdiLRunIrEzD+h0S+e4CyzNwaiP3yIolg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-arm64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.7.6.tgz", + "integrity": "sha512-sM5deBqo0EuXg5GACBUMKEua9jIau/i34bwNlfrf/Amnw1n0GB4/RkuUh+sKiUcbNAntrRq+YhCq8qDP8IW19w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-x64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.7.6.tgz", + "integrity": "sha512-ry63ADGLCB/PU/vNB1VioRt2V+klDJ34frJUXUZBEv1kA96HEAg9AxUk+604o+UHS3ttGH2rkLmrbwHOdAct5Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/interval-tree-1d": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz", + "integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==", + "license": "MIT", + "dependencies": { + "binary-search-bounds": "^2.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "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", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isoformat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz", + "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joi": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/langium": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz", + "integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==", + "license": "MIT", + "dependencies": { + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.3", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-2.1.0.tgz", + "integrity": "sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==", + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/leva": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", + "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", + "dependencies": { + "@radix-ui/react-portal": "^1.1.4", + "@radix-ui/react-tooltip": "^1.1.8", + "@stitches/react": "^1.2.8", + "@use-gesture/react": "^10.2.5", + "colord": "^2.9.2", + "dequal": "^2.0.2", + "merge-value": "^1.0.0", + "react-colorful": "^5.5.1", + "react-dropzone": "^12.0.0", + "v8n": "^1.3.3", + "zustand": "^3.6.9" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "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", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "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" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "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-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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/maxmind": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.6.tgz", + "integrity": "sha512-5bvd/u+kIaTqaGM+xkXjatzQw1dQfSmlLggr2W1EKMyMxSgx2woZyusLpNpZ4DdPmL+1bbJWeo4LXsi6bC0Iew==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "3.0.2", + "tiny-lru": "13.0.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz", + "integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==", + "license": "MIT", + "dependencies": { + "get-value": "^2.0.6", + "is-extendable": "^1.0.0", + "mixin-deep": "^1.2.0", + "set-value": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-value/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mermaid": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/micromark/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-deep/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mmdb-lib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz", + "integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/nanostores": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", + "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-gyp": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ow": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", + "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.2.0", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright-extra": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz", + "integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "playwright": "*", + "playwright-core": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "playwright-core": { + "optional": true + } + } + }, + "node_modules/playwright-extra/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/playwright-extra/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rcedit": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-5.0.2.tgz", + "integrity": "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn-windows-exe": "^1.1.0" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-arborist": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.5.0.tgz", + "integrity": "sha512-FdXOICSt7P2h+Pxin1ULN02b4qrXJznNcshgwwWVtuYMLWSJcD245PQ4HOSj/Lr2T1uEegmnEm5Lbns2hUUsqg==", + "dependencies": { + "react-dnd": "^14.0.3", + "react-dnd-html5-backend": "^14.0.3", + "react-window": "^1.8.11", + "redux": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": ">= 16.14", + "react-dom": ">= 16.14" + } + }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-dnd": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "dependencies": { + "dnd-core": "14.0.1" + } + }, + "node_modules/react-dom": { + "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", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-dropzone": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.1.0.tgz", + "integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.5.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "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", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "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.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react-shiki": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.3.tgz", + "integrity": "sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "dequal": "^2.0.3", + "hast-util-to-jsx-runtime": "^2.3.6", + "shiki": "^4.0.0", + "unist-util-visit": "^5.1.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "@types/react-dom": ">=16.8.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/read-binary-file-arch/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/read-binary-file-arch/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rehype-harden": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/rehype-harden/-/rehype-harden-1.1.8.tgz", + "integrity": "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==", + "license": "MIT", + "dependencies": { + "unist-util-visit": "^5.0.0" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remend": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.0.tgz", + "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "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", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "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" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@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" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sanitize-html": { + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz", + "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^10.1.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "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/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "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", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamdown": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.5.0.tgz", + "integrity": "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==", + "dependencies": { + "clsx": "^2.1.1", + "hast-util-to-jsx-runtime": "^2.3.6", + "html-url-attributes": "^3.0.1", + "marked": "^17.0.1", + "mermaid": "^11.12.2", + "rehype-harden": "^1.1.8", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remend": "1.3.0", + "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/sumchecker/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sumchecker/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "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.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-lru": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz", + "integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "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.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-api-utils": { + "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": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/tw-shimmer": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz", + "integrity": "sha512-pTpGJzp3xaCPO87WeHETngmZHJYvygiSTt4jqzh2oR3DWBoeudi/ANB304zks9+Cm2vQ1ai3w9fetviYdqY8HQ==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=4.0.0-0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicode-animations": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz", + "integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "unicode-animations": "^1.0.1" + }, + "bin": { + "unicode-animations": "scripts/demo.cjs" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-effect-event": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/use-effect-event/-/use-effect-event-2.0.3.tgz", + "integrity": "sha512-fz1en+z3fYXCXx3nMB8hXDMuygBltifNKZq29zDx+xNJ+1vEs6oJlYd9sK31vxJ0YI534VUsHEBY0k2BATsmBQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.3 || ^19.0.0-0" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8n": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz", + "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==", + "license": "MIT" + }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wait-on": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz", + "integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.15.0", + "joi": "^18.1.2", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/web-haptics": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz", + "integrity": "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "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/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 7500796ac..6d519553f 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools.", "private": true, + "workspaces": [ + "apps/*" + ], "scripts": { "postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'" }, @@ -16,6 +19,7 @@ }, "homepage": "https://github.com/NousResearch/Hermes-Agent#readme", "dependencies": { + "@streamdown/math": "^1.0.2", "agent-browser": "^0.26.0" }, "overrides": { diff --git a/plugins/observability/langfuse/README.md b/plugins/observability/langfuse/README.md index 97f4757e5..864735d96 100644 --- a/plugins/observability/langfuse/README.md +++ b/plugins/observability/langfuse/README.md @@ -5,16 +5,20 @@ you explicitly enable it. ## Enable +Pick one: + ```bash +# Interactive: walks you through credentials + SDK install + enable +hermes tools # → Langfuse Observability + +# Manual pip install langfuse hermes plugins enable observability/langfuse ``` -Or check the box in the interactive `hermes plugins` UI. - ## Required credentials -Set these in `~/.hermes/.env`: +Set these in `~/.hermes/.env` (or via `hermes tools`): ```bash HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-... diff --git a/plugins/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index a99a8eb92..8516030fb 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -4,11 +4,11 @@ Traces Hermes conversations, LLM calls, and tool usage to Langfuse. Activation is handled by the Hermes plugin system — standalone plugins only load when listed in ``plugins.enabled`` (via ``hermes plugins enable -observability/langfuse``, or by checking the box in the interactive -``hermes plugins`` UI). At runtime the plugin also requires the -``langfuse`` SDK and credentials; if either is missing the hooks are inert. +observability/langfuse`` or ``hermes tools → Langfuse Observability``). At +runtime the plugin also requires the ``langfuse`` SDK and credentials; if +either is missing the hooks are inert. -Required env vars (set in ~/.hermes/.env): +Required env vars (set via ``hermes tools`` or ~/.hermes/.env): HERMES_LANGFUSE_PUBLIC_KEY - Langfuse project public key (pk-lf-...) HERMES_LANGFUSE_SECRET_KEY - Langfuse project secret key (sk-lf-...) HERMES_LANGFUSE_BASE_URL - Langfuse server URL (default: https://cloud.langfuse.com) diff --git a/plugins/observability/langfuse/plugin.yaml b/plugins/observability/langfuse/plugin.yaml index 708264c8a..18f1c6245 100644 --- a/plugins/observability/langfuse/plugin.yaml +++ b/plugins/observability/langfuse/plugin.yaml @@ -1,6 +1,6 @@ name: langfuse version: "1.0.0" -description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Opt-in via `hermes plugins enable observability/langfuse` (or check the box in `hermes plugins`)." +description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Opt-in via `hermes plugins enable observability/langfuse` or `hermes tools → Langfuse Observability`." author: NousResearch requires_env: - HERMES_LANGFUSE_PUBLIC_KEY diff --git a/pyproject.toml b/pyproject.toml index a0776cfae..86bd94c54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,10 @@ dependencies = [ # (which is a silent killer on Windows — see CONTRIBUTING.md) and # `os.killpg` (which doesn't exist on Windows). "psutil==7.2.2", + "fastapi>=0.104.0,<1", + "uvicorn[standard]>=0.24.0,<1", + "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", + "pywinpty>=2.0.0,<3; sys_platform == 'win32'", ] [project.optional-dependencies] @@ -104,8 +108,10 @@ voice = [ "numpy==2.4.3", ] pty = [ - "ptyprocess==0.7.0; sys_platform != 'win32'", - "pywinpty==2.0.15; sys_platform == 'win32'", + # Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now + # in the main `dependencies` list (with the same platform markers), so + # any existing `pip install hermes-agent[pty]` invocations resolve cleanly + # without pulling in extra packages. ] honcho = ["honcho-ai==2.0.1"] # CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's diff --git a/run_agent.py b/run_agent.py index 18ca74890..34d112cb3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1645,6 +1645,63 @@ class AIAgent: return True return False + @staticmethod + def _decorate_xai_entitlement_error(detail: str) -> str: + """Append a neutral hint when xAI's OAuth surface returns the + permission-denied 403. + + xAI's ``/v1/responses`` endpoint replies to several distinct failure + modes with the SAME body:: + + {"code": "The caller does not have permission to execute the + specified operation", "error": "You have either run out of + available resources or do not have an active Grok subscription. + Manage subscriptions at https://grok.com/?_s=usage or subscribe + at https://grok.com/supergrok"} + + That body covers several real causes we cannot distinguish without + more info from xAI. The most common (and least obvious) one is + that **X Premium+ does NOT include API access** — only standalone + SuperGrok subscribers can use Hermes against xai-oauth. Lots of + users see Grok in their X app, assume it works here too, and hit + this 403 with no idea why. Lead the hint with that. + + Other possible causes: + * No Grok subscription at all + * SuperGrok tier doesn't include the requested model (e.g. + grok-4.3 may need a higher tier) + * Monthly quota exhausted (the ``?_s=usage`` URL hints at this) + + Surface the raw xAI text verbatim and point at + https://grok.com/?_s=usage where the user can see WHICH applies. + + Matched once per detail string — won't double-decorate if the + upstream already concatenated the same text. + """ + if not detail: + return detail + lower = detail.lower() + is_entitlement = ( + "do not have an active grok subscription" in lower + or ("out of available resources" in lower and "grok" in lower) + or ("does not have permission" in lower and "grok" in lower) + ) + if not is_entitlement: + return detail + hint = ( + " — xAI rejected this OAuth account. NOTE: X Premium+ does NOT " + "include xAI API access — only standalone SuperGrok subscribers " + "can use this provider. Other possible causes: no Grok " + "subscription, your tier doesn't include this model, or your " + "quota is exhausted. Check https://grok.com/?_s=usage to see " + "which, or run `/model` to switch providers." + ) + # Idempotency: detect prior decoration by a substring unique to the + # hint (not present in xAI's own body text). + if "X Premium+ does NOT include" in detail: + return detail + return f"{detail}{hint}" + @staticmethod def _summarize_api_error(error: Exception) -> str: """Extract a human-readable one-liner from an API error. @@ -1684,12 +1741,12 @@ class AIAgent: if msg: status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" - return f"{prefix}{msg[:300]}" + return AIAgent._decorate_xai_entitlement_error(f"{prefix}{msg[:300]}") # Fallback: truncate the raw string but give more room than 200 chars status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" - return f"{prefix}{raw[:500]}" + return AIAgent._decorate_xai_entitlement_error(f"{prefix}{raw[:500]}") def _mask_api_key_for_logs(self, key: Any) -> Optional[str]: # Azure Foundry Entra ID bearer providers are callables — never diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 343a9c181..bed44ef13 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -23,8 +23,8 @@ param( # exact ref. Precedence: Commit > Tag > Branch. [string]$Commit = "", [string]$Tag = "", - [string]$HermesHome = "$env:LOCALAPPDATA\hermes", - [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent", + [string]$HermesHome = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }), + [string]$InstallDir = $(if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" }), # --- Stage protocol (additive; default invocation behaves as before) ---- # See the "Stage protocol" section near the bottom of the file for the @@ -39,7 +39,24 @@ param( # --- Ensure mode (dep_ensure.py entry point) --- [string]$Ensure = "", - [switch]$PostInstall + [switch]$PostInstall, + + # --- Desktop GUI build (opt-in) --- + # When set, install.ps1 includes Stage-Desktop in the manifest and + # builds apps/desktop into a launchable Hermes.exe. + # + # Why opt-in: + # * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes + # -IncludeDesktop so a user who installed via the GUI ends up + # with a launchable desktop binary. + # * The Electron desktop's own bootstrap-runner.cjs runs install.ps1 + # from inside an already-launched Hermes.exe; if THAT recursively + # built apps/desktop it would try to overwrite the live Hermes.exe + # on disk and fail. The recursive path omits the flag. + # * The canonical CLI one-liner (irm | iex) omits the flag too; + # terminal users don't need a desktop binary built for them, and + # `hermes desktop` already builds on demand. + [switch]$IncludeDesktop ) $ErrorActionPreference = "Stop" @@ -87,6 +104,55 @@ $InstallStageProtocolVersion = 1 # ============================================================================ # Helper functions + +# Return the real OS processor architecture as a lowercase string suitable for +# Node.js / electron download URL slugs: "arm64", "x64", or "x86". +# +# Why not just trust [Environment]::Is64BitOperatingSystem or +# [RuntimeInformation]::OSArchitecture? On Windows on ARM, when this script +# is invoked from Windows PowerShell 5.1 (the default `powershell.exe`) or +# any x64 PowerShell host, the process runs under Prism x64 emulation and +# BOTH of those APIs report `X64` -- they describe the emulated view, not +# the real OS. We've seen this concretely on Snapdragon X1 hardware: an +# ARM64-based Surface Laptop returns OSArchitecture=X64 from an emulated +# PowerShell session. +# +# Win32_Processor.Architecture is invariant to emulation. Values: +# 0=x86, 5=ARM, 9=AMD64/x64, 12=ARM64. We fall back to +# PROCESSOR_ARCHITEW6432 (set on WoW64 with the real OS arch) and then +# PROCESSOR_ARCHITECTURE so we still produce a sensible answer if CIM +# isn't available (locked-down WMI, container, etc.). +function Get-WindowsArch { + try { + $proc = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | + Select-Object -First 1 + switch ([int]$proc.Architecture) { + 12 { return "arm64" } + 9 { return "x64" } + 0 { return "x86" } + 5 { return "arm" } + } + } catch { + # CIM unavailable -- fall through to env-var path + } + + $envArch = if ($env:PROCESSOR_ARCHITEW6432) { + $env:PROCESSOR_ARCHITEW6432 + } else { + $env:PROCESSOR_ARCHITECTURE + } + switch ($envArch) { + "ARM64" { return "arm64" } + "AMD64" { return "x64" } + "x86" { return "x86" } + default { + # Last-resort: respect 64-bitness so we don't ship a 32-bit + # toolchain to anyone. + if ([Environment]::Is64BitOperatingSystem) { return "x64" } else { return "x86" } + } + } +} + # ============================================================================ function Write-Banner { @@ -525,17 +591,18 @@ function Install-Git { Write-Info "(no admin rights required; isolated from any system Git install)" try { - $arch = if ([Environment]::Is64BitOperatingSystem) { - # Detect ARM64 vs x64 explicitly; PortableGit ships separate assets. - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") { - "arm64" - } else { - "64-bit" - } + $arch = Get-WindowsArch + if ($arch -eq 'arm64') { + $assetTag = 'arm64' + $downloadIsZip = $false + } elseif ($arch -eq 'x64') { + $assetTag = '64-bit' + $downloadIsZip = $false } else { - # PortableGit does not ship a 32-bit build -- fall back to MinGit 32-bit - # with a warning that bash-based features will be unavailable. - "32-bit-mingit" + # PortableGit does not ship 32-bit / arm builds -- fall back to MinGit + # 32-bit with a warning that bash-based features will be unavailable. + $assetTag = '32-bit-mingit' + $downloadIsZip = $true } # Pinned git-for-windows release. We deliberately do NOT hit @@ -721,7 +788,7 @@ function Test-Node { Write-Info "Downloading portable Node.js $NodeVersion to $HermesHome\node\ ..." Write-Info "(no admin rights required; isolated from any system Node install)" try { - $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } + $arch = Get-WindowsArch $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" $indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing $zipName = ($indexPage.Content | Select-String -Pattern "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip" -AllMatches).Matches[0].Value @@ -783,7 +850,19 @@ function Test-Node { # check the post-condition. See the long comment in Install-Uv # for the same pattern. $ErrorActionPreference = "Continue" - winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null + # On ARM64, force winget to fetch the ARM64 installer. Without + # the explicit override, winget on WoW64 sometimes still resolves + # to x64 manifests, leaving us with an emulated Node toolchain + # even after a "successful" install. The OpenJS manifest does + # publish an arm64 installer, so this is safe. + $wingetArgs = @( + 'install','OpenJS.NodeJS.LTS','--silent', + '--accept-package-agreements','--accept-source-agreements' + ) + if ((Get-WindowsArch) -eq 'arm64') { + $wingetArgs += @('--architecture','arm64') + } + winget @wingetArgs 2>&1 | Out-Null $ErrorActionPreference = $prevEAP # Refresh PATH $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") @@ -856,22 +935,57 @@ function Install-SystemPackages { # Try winget first (most common on modern Windows) if ($hasWinget) { Write-Info "Installing $description via winget..." + # Per-package log paths -- key the lookup by package id so we can + # decide AFTER the post-install Get-Command check whether to keep + # the log (still missing -> keep as breadcrumb) or delete it (now + # present -> happy path, no clutter). + $pkgLogs = @{} foreach ($pkg in $wingetPkgs) { + $log = "$env:TEMP\hermes-winget-$($pkg -replace '[^A-Za-z0-9]','_')-$(Get-Random).log" + $pkgLogs[$pkg] = $log + # --source winget pins us to the github-backed source. Without this, + # a broken msstore source (cert validation failures like 0x8a15005e + # are common on Windows-on-ARM and some corporate networks) makes + # winget bail with "please specify --source" *before* attempting any + # install -- and it exits 0, so the surrounding try/catch never fires. + # We don't ship anything from msstore, so pinning is safe. try { - winget install $pkg --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null - } catch { } + $output = winget install --exact --id $pkg --source winget --silent ` + --accept-package-agreements --accept-source-agreements 2>&1 + $output | Out-File -FilePath $log -Encoding utf8 + "winget exit: $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append + } catch { + $_ | Out-File -FilePath $log -Encoding utf8 -Append + "winget exit: <exception>" | Out-File -FilePath $log -Encoding utf8 -Append + } } - # Refresh PATH and recheck - $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + # Refresh PATH from both env-var hives AND winget's alias shim directory. + # winget exposes packages via "command line aliases" in %LOCALAPPDATA%\ + # Microsoft\WinGet\Links, which is added to PATH by the AppExecutionAlias + # machinery only in *newly-spawned* shells -- not the current process. + # Without this addition, Get-Command rg below would falsely return null + # immediately after a successful install. + $wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links" + $envPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + if (Test-Path $wingetLinks) { + $envPath = "$envPath;$wingetLinks" + } + $env:Path = $envPath if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed" $script:HasRipgrep = $true $needRipgrep = $false + Remove-Item -Path $pkgLogs["BurntSushi.ripgrep.MSVC"] -ErrorAction SilentlyContinue + } elseif ($pkgLogs.ContainsKey("BurntSushi.ripgrep.MSVC")) { + Write-Warn "winget could not install ripgrep; details: $($pkgLogs['BurntSushi.ripgrep.MSVC'])" } if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { Write-Success "ffmpeg installed" $script:HasFfmpeg = $true $needFfmpeg = $false + Remove-Item -Path $pkgLogs["Gyan.FFmpeg"] -ErrorAction SilentlyContinue + } elseif ($pkgLogs.ContainsKey("Gyan.FFmpeg")) { + Write-Warn "winget could not install ffmpeg; details: $($pkgLogs['Gyan.FFmpeg'])" } if (-not $needRipgrep -and -not $needFfmpeg) { return } } @@ -1410,6 +1524,83 @@ function Set-PathVariable { Write-Success "hermes command ready" } +function Write-BootstrapMarker { + # Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes + # desktop app (apps/desktop/electron/main.cjs) "install.ps1 ran + # successfully — DON'T trigger the legacy first-launch bootstrap + # runner." + # + # Schema mirrors what main.cjs's writeBootstrapMarker() / isBootstrap + # Complete() expect. Keep this in lockstep when either side changes: + # apps/desktop/electron/main.cjs lines 1199-1222 + # BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 (line 187) + # + # Pinned commit/branch come from -Commit + -Branch flags (passed by + # Hermes-Setup.exe) or fall back to whatever git resolves in the + # checkout. The desktop validates schemaVersion + pinnedCommit + # length but doesn't enforce that HEAD matches the pin (users + # update via `hermes update` which moves HEAD legitimately). + if (-not (Test-Path $InstallDir)) { + Write-Warn "Skipping bootstrap marker: $InstallDir doesn't exist" + return + } + + # Resolve the pinned commit: explicit -Commit wins, otherwise read + # the checkout's HEAD via git. If git can't run, leave commit empty + # and the marker will fail desktop validation (pinnedCommit.length + # >= 7) — better to be invalid than wrong. + $pinnedCommit = $Commit + if (-not $pinnedCommit) { + # PS 5.1 doesn't support the ?. null-conditional operator, so + # check Get-Command's result explicitly before reading .Source. + $gitCmd = Get-Command git -ErrorAction SilentlyContinue + $gitExe = if ($gitCmd) { $gitCmd.Source } else { $null } + if ($gitExe) { + Push-Location $InstallDir + try { + $resolved = & $gitExe rev-parse HEAD 2>$null + if ($LASTEXITCODE -eq 0 -and $resolved) { + $pinnedCommit = $resolved.Trim() + } + } catch { + # Ignore — pinnedCommit stays empty, marker stays invalid, + # desktop falls through to its legacy bootstrap path. + } finally { + Pop-Location + } + } + } + + $pinnedBranch = $Branch + if (-not $pinnedBranch) { + $pinnedBranch = "main" # install.ps1's own default for -Branch + } + + $markerPath = Join-Path $InstallDir ".hermes-bootstrap-complete" + $marker = [ordered]@{ + schemaVersion = 1 + pinnedCommit = $pinnedCommit + pinnedBranch = $pinnedBranch + completedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") + # desktopVersion field intentionally omitted — only the desktop + # app knows its own version, and the marker validator doesn't + # require it. The desktop fills it in if/when it writes its + # own marker (e.g. after a future in-app upgrade). + } + $json = $marker | ConvertTo-Json -Compress:$false + + # Write WITHOUT a UTF-8 BOM. PowerShell 5.1's `Set-Content -Encoding UTF8` + # always emits a BOM, and Node's plain JSON.parse rejects the BOM as an + # unexpected character — so a BOM'd marker would silently fail the + # desktop's readJson(), make isBootstrapComplete() return null, and the + # desktop would re-run the legacy bootstrap runner anyway. Defeats the + # whole point. Use the .NET API directly for BOM-less UTF-8. + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($markerPath, $json, $utf8NoBom) + + Write-Success "Bootstrap marker written: $markerPath" +} + function Copy-ConfigTemplates { Write-Info "Setting up configuration files..." @@ -1508,8 +1699,15 @@ Delete the contents (or this file) to use the default personality. function Install-NodeDeps { if (-not $HasNode) { - Write-Info "Skipping Node.js dependencies (Node not installed)" - return + # Cross-process driver mode (Hermes-Setup.exe runs each -Stage NAME + # in a fresh powershell.exe) means $script:HasNode set by Stage-Node + # in the previous process isn't visible here. Re-probe rather than + # trust the stale global — Stage-Node already ran successfully or + # the bootstrap would've aborted, so npm is reachable. + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Info "Skipping Node.js dependencies (Node not installed)" + return + } } # Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows @@ -1723,6 +1921,249 @@ function Install-NodeDeps { } } +function Install-Desktop { + # Build apps/desktop into a launchable Hermes.exe. Only called from + # Stage-Desktop, which is itself only included in the manifest when + # -IncludeDesktop was passed to install.ps1. + # + # The workspace npm install at repo root (done by Install-NodeDeps for + # browser tools) does NOT pull apps/desktop's dependencies, because the + # browser-tools workspace at $InstallDir\package.json is a separate + # workspace from apps/*. We do a full root-level `npm install` here + # so the workspace resolves apps/desktop's deps (including Electron + # itself, ~150MB), then run `npm run pack` in apps/desktop which + # produces the unpacked binary at apps/desktop/release/<os>-unpacked/. + # + # The Tauri bootstrap installer's launch_hermes_desktop command + # resolves apps/desktop/release/win-unpacked/Hermes.exe directly, + # so an "unpacked" build (electron-builder --dir) is enough — we + # don't need to produce an NSIS/MSI artifact here. + + if (-not $HasNode) { + # Cross-process driver mode: each `-Stage NAME` invocation runs in a + # fresh PowerShell process, so $script:HasNode set by Stage-Node + # in the previous process isn't visible. Re-detect rather than + # trusting the global. + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Warn "Skipping desktop build (Node.js / npm not on PATH)" + $script:_StageSkippedReason = "Node.js not available" + return + } + } + + $desktopDir = "$InstallDir\apps\desktop" + if (-not (Test-Path "$desktopDir\package.json")) { + Write-Warn "Skipping desktop build (apps/desktop not present in checkout)" + $script:_StageSkippedReason = "apps/desktop not present" + return + } + + $npmCmd = Get-Command npm -ErrorAction SilentlyContinue + if (-not $npmCmd) { + Write-Warn "Skipping desktop build (npm not on PATH)" + $script:_StageSkippedReason = "npm not found" + return + } + $npmExe = $npmCmd.Source + if ($npmExe -like "*.ps1") { + $sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $sibling) { $npmExe = $sibling } + } + + # 1. Workspace-level install so apps/desktop's deps (Electron, Vite, + # node-pty prebuilds, etc.) actually land in node_modules. This is + # the SAME `npm install` Install-NodeDeps does for browser tools, + # but at the root rather than the browser-tools workspace, so all + # apps/* workspaces resolve. + Write-Info "Installing desktop workspace dependencies (this includes Electron ~150MB, takes 1-3min)..." + Push-Location $InstallDir + $prevEAP = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + # Drop --silent so npm emits its full progress + error trail. + # When this fails on a non-dev box (e.g. native-module build + # without VS Build Tools, ETARGET on a transitive, etc.), the + # actual reason needs to reach the Tauri installer's log; with + # --silent it was completely suppressed and the user just saw + # "exit 1" with no actionable detail. + # + # The streaming sink in bootstrap.rs's run_install_script + # captures every stdout/stderr line as it's emitted, so we don't + # need a side TEMP log file — the installer's bootstrap log + # IS the artifact a support engineer reads. + & $npmExe install 2>&1 | ForEach-Object { "$_" } + $code = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($code -ne 0) { + throw "desktop workspace npm install failed (exit $code) -- see lines above for cause" + } + Write-Success "Desktop workspace dependencies installed" + } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + Pop-Location + throw + } + Pop-Location + + # 2. Build apps/desktop. `npm run pack` runs: + # assert-root-install + write-build-stamp + stage-native-deps + + # tsc -b + vite build + electron-builder --dir + # The --dir mode produces an unpacked Hermes.exe in + # apps/desktop/release/win-unpacked/ without bundling NSIS/MSI; + # we don't need a distributable installer artifact, just a + # launchable binary the Tauri installer can spawn. + # + # CSC_IDENTITY_AUTO_DISCOVERY=false tells electron-builder we are + # NOT signing the output. Combined with signAndEditExecutable=false in + # apps/desktop/package.json's build.win block, electron-builder never + # invokes signtool and therefore never fetches/extracts winCodeSign + # (whose macOS symlinks crash 7-Zip on non-admin Windows — a dead end we + # are NOT trying to work around). The Hermes icon + product name are + # stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity) + # AFTER this build, completely decoupled from electron-builder signing. + # + # WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD explicitly cleared as + # belt-and-suspenders: if the user's environment has them set + # for some other tool, electron-builder would still try to sign. + Write-Info "Building desktop app (this takes 1-3 minutes)..." + $buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log" + Push-Location $desktopDir + $prevEAP = $ErrorActionPreference + $prevCSCAuto = $env:CSC_IDENTITY_AUTO_DISCOVERY + $prevWinCscLink = $env:WIN_CSC_LINK + $prevWinCscKeyPassword = $env:WIN_CSC_KEY_PASSWORD + try { + $ErrorActionPreference = "Continue" + $env:CSC_IDENTITY_AUTO_DISCOVERY = "false" + $env:WIN_CSC_LINK = "" + $env:WIN_CSC_KEY_PASSWORD = "" + & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog + $code = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($code -ne 0) { + $errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue + if ($errText) { + $snippet = if ($errText.Length -gt 1800) { $errText.Substring(0, 1800) + "..." } else { $errText } + Write-Info " desktop build output:" + foreach ($line in $snippet -split "`n") { Write-Host " $line" -ForegroundColor DarkGray } + Write-Info " Full log: $buildLog" + } + throw "apps/desktop build failed (exit $code)" + } + Write-Success "Desktop app built" + Remove-Item -Force $buildLog -ErrorAction SilentlyContinue + } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + Pop-Location + throw + } finally { + # Restore env to whatever the caller had — don't leak our + # signing-off override into anything install.ps1 invokes later + # (Stage-PlatformSdks, etc.). + $env:CSC_IDENTITY_AUTO_DISCOVERY = $prevCSCAuto + $env:WIN_CSC_LINK = $prevWinCscLink + $env:WIN_CSC_KEY_PASSWORD = $prevWinCscKeyPassword + } + Pop-Location + + # 3. Sanity-check the produced binary. Probe both arches so this works + # on x64 and arm64 build machines. + $exeCandidates = @( + "$desktopDir\release\win-unpacked\Hermes.exe", + "$desktopDir\release\win-arm64-unpacked\Hermes.exe" + ) + $found = $false + $desktopExe = $null + foreach ($cand in $exeCandidates) { + if (Test-Path $cand) { + Write-Success "Desktop ready: $cand" + $desktopExe = $cand + $found = $true + break + } + } + if (-not $found) { + throw "Desktop build completed but no Hermes.exe was found under $desktopDir\release\*-unpacked\" + } + + # 3b. The Hermes icon + identity are stamped onto Hermes.exe by the + # electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.cjs) + # during `npm run pack` above — for every build, so the installer's + # --update rebuild stays branded too. No separate stamp step needed here. + # electron-builder's own rcedit step stays disabled (signAndEditExecutable + # =false) because enabling it drags in signtool -> winCodeSign -> the + # unfixable symlink crash; the afterPack hook runs rcedit directly. + + # 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed + # Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that + # command rebuilds (npm install + electron-builder) on every launch, + # which would cost minutes each time. The packed exe is the consumer — + # launching it directly is instant, and updates flow through the + # installer's --update path (which rebuilds once, then relaunches). + New-DesktopShortcuts -TargetExe $desktopExe +} + +function New-DesktopShortcuts { + param([Parameter(Mandatory = $true)][string]$TargetExe) + + # Best-effort: a shortcut failure must never fail an otherwise-good install. + try { + $shell = New-Object -ComObject WScript.Shell + $workDir = Split-Path -Parent $TargetExe + + # Prefer the standalone icon.ico (shipped beside the exe via + # electron-builder extraResources -> resources/icon.ico) over the exe's + # embedded resource. An explicit .ico path is more stable across update + # cycles: pointing at "$TargetExe,0" makes Windows cache the icon it + # extracted from the exe at shortcut-creation time, and that cached + # bitmap can persist (showing the OLD/Electron icon) even after the exe + # is re-stamped on update. A dedicated .ico sidesteps that extraction. + $iconIco = Join-Path $workDir 'resources\icon.ico' + if (Test-Path $iconIco) { + $iconLocation = "$iconIco,0" + } else { + $iconLocation = "$TargetExe,0" + } + + $targets = @( + (Join-Path ([Environment]::GetFolderPath('Programs')) 'Hermes.lnk'), + (Join-Path ([Environment]::GetFolderPath('Desktop')) 'Hermes.lnk') + ) + + foreach ($lnkPath in $targets) { + try { + $parent = Split-Path -Parent $lnkPath + if (-not (Test-Path $parent)) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + $sc = $shell.CreateShortcut($lnkPath) + $sc.TargetPath = $TargetExe + $sc.WorkingDirectory = $workDir + $sc.IconLocation = $iconLocation + $sc.Description = 'Hermes Agent' + $sc.Save() + Write-Success "Shortcut created: $lnkPath" + } catch { + Write-Warn "Could not create shortcut $lnkPath : $($_.Exception.Message)" + } + } + + # Bust the Windows shell icon cache so the desktop/Start-Menu shortcut + # repaints with the (possibly newly-stamped) icon instead of a stale + # cached bitmap. Critical on the --update path: the exe was re-stamped + # with the Hermes icon, but without this the shortcut can keep drawing + # the old Electron icon until the user manually refreshes / reboots. + # Best-effort and silent — never fail the install over a cosmetic cache. + try { + & ie4uinit.exe -show 2>$null + } catch { + # ie4uinit may be absent/renamed on some SKUs — ignore. + } + } catch { + Write-Warn "Skipping shortcut creation: $($_.Exception.Message)" + } +} + function Install-PlatformSdks { # Ensure messaging-platform SDKs matching tokens the user added to # ~/.hermes/.env are importable. Two problems this solves: @@ -2080,9 +2521,18 @@ $InstallStages = @( @{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" } @{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" } @{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" } +) +if ($IncludeDesktop) { + # Insert AFTER node-deps so workspace npm is already installed when + # the desktop build runs. Inserted only when explicitly requested + # (Hermes-Setup.exe), never via the irm|iex CLI one-liner. + $InstallStages += @{ Name = "desktop"; Title = "Building desktop app"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Desktop" } +} +$InstallStages += @( @{ Name = "path"; Title = "Adding Hermes to PATH"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" } @{ Name = "config-templates"; Title = "Writing configuration templates"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" } @{ Name = "platform-sdks"; Title = "Installing messaging platform SDKs"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" } + @{ Name = "bootstrap-marker"; Title = "Marking install complete"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-BootstrapMarker" } # Interactive stages. In non-interactive mode these become no-ops; the # caller (GUI / CI) handles the equivalent UX themselves. @{ Name = "configure"; Title = "Configuring API keys and models"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Configure" } @@ -2119,9 +2569,11 @@ function Stage-Repository { Install-Repository } function Stage-Venv { Resolve-UvCmd; Install-Venv } function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies } function Stage-NodeDeps { Install-NodeDeps } +function Stage-Desktop { Install-Desktop } function Stage-Path { Set-PathVariable } function Stage-ConfigTemplates { Copy-ConfigTemplates } function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } +function Stage-BootstrapMarker { Write-BootstrapMarker } function Stage-Configure { Invoke-SetupWizard } function Stage-Gateway { Start-GatewayIfConfigured } diff --git a/scripts/install.sh b/scripts/install.sh index 92cfc4ee2..8af1c14f0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -71,8 +71,14 @@ USE_VENV=true RUN_SETUP=true SKIP_BROWSER=false BRANCH="main" +INSTALL_COMMIT="" ENSURE_DEPS="" POSTINSTALL_MODE=false +MANIFEST_MODE=false +STAGE_NAME="" +JSON_OUTPUT=false +NON_INTERACTIVE=false +INCLUDE_DESKTOP=false # Detect non-interactive mode (e.g. curl | bash) # When stdin is not a terminal, read -p will fail with EOF, @@ -98,10 +104,34 @@ while [[ $# -gt 0 ]]; do SKIP_BROWSER=true shift ;; - --branch) + --branch|-Branch) BRANCH="$2" shift 2 ;; + --commit|-Commit) + INSTALL_COMMIT="$2" + shift 2 + ;; + --manifest|-Manifest) + MANIFEST_MODE=true + shift + ;; + --stage|-Stage) + STAGE_NAME="$2" + shift 2 + ;; + --json|-Json) + JSON_OUTPUT=true + shift + ;; + --non-interactive|-NonInteractive) + NON_INTERACTIVE=true + shift + ;; + --include-desktop|-IncludeDesktop) + INCLUDE_DESKTOP=true + shift + ;; --dir) INSTALL_DIR="$2" INSTALL_DIR_EXPLICIT=true @@ -129,6 +159,12 @@ while [[ $# -gt 0 ]]; do echo " --skip-setup Skip interactive setup wizard" echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)" echo " --branch NAME Git branch to install (default: main)" + echo " --commit SHA Pin checkout to a specific commit after clone/update" + echo " --manifest Print desktop bootstrap stage manifest as JSON" + echo " --stage NAME Run one desktop bootstrap stage" + echo " --json Print a JSON result frame for --stage" + echo " --non-interactive Skip stages that require user input" + echo " --include-desktop Also build the desktop app (apps/desktop -> Hermes.app)" echo " --dir PATH Installation directory" echo " default (non-root): ~/.hermes/hermes-agent" echo " default (root, Linux): /usr/local/lib/hermes-agent" @@ -189,6 +225,66 @@ log_error() { echo -e "${RED}✗${NC} $1" } +json_escape() { + # Enough for short installer status strings; avoids requiring jq during + # pre-install bootstrap. + printf '%s' "$1" | tr '\n' ' ' | sed \ + -e 's/\\/\\\\/g' \ + -e 's/"/\\"/g' +} + +# npm rewrites tracked package-lock.json files non-deterministically during +# `npm install` / `npm run pack`. On a managed install those diffs are never +# intentional, but they leave the checkout dirty — which forces `hermes update` +# to autostash on every run and makes branch switches fragile. Restore them so +# a fresh install ends with a clean tree. Best-effort; only touches lockfiles. +restore_dirty_lockfiles() { + local repo="${1:-$INSTALL_DIR}" + [ -n "$repo" ] && [ -d "$repo/.git" ] || return 0 + command -v git >/dev/null 2>&1 || return 0 + local dirty + dirty=$(git -C "$repo" diff --name-only 2>/dev/null | grep 'package-lock\.json$' || true) + [ -z "$dirty" ] && return 0 + echo "$dirty" | while IFS= read -r f; do + [ -n "$f" ] && git -C "$repo" checkout -- "$f" 2>/dev/null || true + done +} + +emit_manifest() { + # Stage-Desktop is included only with --include-desktop, mirroring + # install.ps1: the signed bootstrap installer (Hermes-Setup) passes it so + # a GUI install ends up with a launchable app; the Electron app's own + # first-launch bootstrap and the CLI one-liner omit it (building the + # desktop from inside the already-running app would clobber it). + local desktop_stage="" + if [ "$INCLUDE_DESKTOP" = true ]; then + desktop_stage='{"name":"desktop","title":"Build desktop app","category":"runtime","needs_user_input":false},' + fi + printf '%s' '{"protocol_version":1,"stages":[{"name":"prerequisites","title":"System prerequisites","category":"runtime","needs_user_input":false},{"name":"repository","title":"Download Hermes Agent","category":"runtime","needs_user_input":false},{"name":"venv","title":"Create Python virtual environment","category":"runtime","needs_user_input":false},{"name":"python-deps","title":"Install Python dependencies","category":"runtime","needs_user_input":false},{"name":"node-deps","title":"Install browser-tool dependencies","category":"runtime","needs_user_input":false},{"name":"path","title":"Install hermes command","category":"runtime","needs_user_input":false},{"name":"config","title":"Prepare config and skills","category":"configuration","needs_user_input":false},{"name":"setup","title":"Configure API keys and settings","category":"configuration","needs_user_input":true},{"name":"gateway","title":"Configure gateway service","category":"configuration","needs_user_input":true},'"$desktop_stage"'{"name":"complete","title":"Finish install","category":"runtime","needs_user_input":false}]}' + printf '\n' +} + +stage_needs_user_input() { + case "$1" in + setup|gateway) return 0 ;; + *) return 1 ;; + esac +} + +emit_stage_json() { + local stage="$1" + local ok="$2" + local skipped="${3:-false}" + local reason="${4:-}" + local escaped_reason + escaped_reason="$(json_escape "$reason")" + if [ -n "$escaped_reason" ]; then + printf '{"ok":%s,"stage":"%s","skipped":%s,"reason":"%s"}\n' "$ok" "$stage" "$skipped" "$escaped_reason" + else + printf '{"ok":%s,"stage":"%s","skipped":%s}\n' "$ok" "$stage" "$skipped" + fi +} + prompt_yes_no() { local question="$1" local default="${2:-yes}" @@ -201,7 +297,9 @@ prompt_yes_no() { *) prompt_suffix="[y/N]" ;; esac - if [ "$IS_INTERACTIVE" = true ]; then + if [ "$NON_INTERACTIVE" = true ]; then + answer="" + elif [ "$IS_INTERACTIVE" = true ]; then read -r -p "$question $prompt_suffix " answer || answer="" elif [ -r /dev/tty ] && [ -w /dev/tty ]; then printf "%s %s " "$question" "$prompt_suffix" > /dev/tty @@ -589,10 +687,78 @@ ensure_fts5() { _warn_no_fts5 } +# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git +# (which downloads PortableGit on Windows). git is required to clone the repo, +# and a fresh "normie" machine with no developer tools won't have it. Returns 0 +# if git is available afterwards, non-zero otherwise (caller prints manual +# instructions and aborts). +attempt_install_git() { + case "$OS" in + macos) + # Prefer Homebrew — fully headless when present. + if command -v brew >/dev/null 2>&1; then + log_info "Installing Git via Homebrew..." + brew install git >/dev/null 2>&1 || true + command -v git >/dev/null 2>&1 && return 0 + fi + # Fall back to Apple Command Line Tools, which provide git AND the + # compiler some Python wheels need. `xcode-select --install` pops a + # system dialog (Apple gates CLT behind it — it cannot be fully + # silent without MDM), so we trigger it and poll for git to appear. + if command -v xcode-select >/dev/null 2>&1; then + log_info "Requesting Apple Command Line Tools (provides git + compiler)..." + log_info "If a macOS dialog appears, click \"Install\" and accept the license." + xcode-select --install >/dev/null 2>&1 || true + local waited=0 + local timeout=900 + while [ "$waited" -lt "$timeout" ]; do + if command -v git >/dev/null 2>&1 && git --version >/dev/null 2>&1; then + return 0 + fi + sleep 5 + waited=$((waited + 5)) + if [ $((waited % 60)) -eq 0 ]; then + log_info "Still waiting for Command Line Tools install ($((waited / 60))m)..." + fi + done + fi + return 1 + ;; + linux) + local sudo_cmd="" + if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then + command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo" + fi + case "$DISTRO" in + ubuntu|debian) + log_info "Installing Git via apt..." + $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true + $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null 2>&1 || true + ;; + fedora) + log_info "Installing Git via dnf..." + $sudo_cmd dnf install -y git >/dev/null 2>&1 || true + ;; + arch) + log_info "Installing Git via pacman..." + $sudo_cmd pacman -S --noconfirm git >/dev/null 2>&1 || true + ;; + *) + return 1 + ;; + esac + command -v git >/dev/null 2>&1 && return 0 + return 1 + ;; + esac + return 1 +} + check_git() { log_info "Checking Git..." - if command -v git &> /dev/null; then + # On fresh macOS /usr/bin/git is a stub that exits non-zero until CLT is installed. + if command -v git &> /dev/null && git --version &> /dev/null; then GIT_VERSION=$(git --version | awk '{print $3}') log_success "Git $GIT_VERSION found" return 0 @@ -610,7 +776,15 @@ check_git() { fi fi - log_info "Please install Git:" + # Try to install it automatically before giving up (parity with install.ps1). + log_info "Attempting to install Git automatically..." + if attempt_install_git; then + GIT_VERSION=$(git --version | awk '{print $3}') + log_success "Git $GIT_VERSION installed" + return 0 + fi + + log_warn "Could not install Git automatically. Please install it manually:" case "$OS" in linux) @@ -1086,6 +1260,14 @@ clone_repo() { cd "$INSTALL_DIR" + if [ -n "$INSTALL_COMMIT" ]; then + log_info "Pinning checkout to commit $INSTALL_COMMIT..." + if ! git cat-file -e "$INSTALL_COMMIT^{commit}" 2>/dev/null; then + git fetch origin "$INSTALL_COMMIT" || true + fi + git checkout --detach "$INSTALL_COMMIT" + fi + log_success "Repository ready" } @@ -1797,7 +1979,8 @@ install_node_deps() { log_success "TUI dependencies installed" fi - + # Keep the checkout clean so `hermes update` doesn't autostash every run. + restore_dirty_lockfiles "$INSTALL_DIR" } run_setup_wizard() { @@ -2140,6 +2323,209 @@ postinstall_mode() { fi } +# Build apps/desktop into a launchable Hermes.app. Mirrors install.ps1's +# Install-Desktop: a root-level npm install so the apps/* workspace resolves +# the desktop's own deps (Electron ~150MB), then `npm run pack` +# (electron-builder --dir) which emits release/mac*/Hermes.app. Only invoked +# via the 'desktop' stage / --include-desktop, which the Electron app's own +# first-launch bootstrap never requests (it must not rebuild itself). +install_desktop() { + local desktop_dir="$INSTALL_DIR/apps/desktop" + + if ! command -v npm >/dev/null 2>&1; then + log_warn "Skipping desktop build (Node.js / npm not on PATH)" + return 0 + fi + if [ ! -f "$desktop_dir/package.json" ]; then + log_warn "Skipping desktop build (apps/desktop not present in checkout)" + return 0 + fi + + # 1. Root workspace install so apps/desktop's deps (Electron, Vite, + # node-pty prebuilds) resolve. The browser-tools install runs in the + # repo-root package workspace, which does not pull apps/* deps. + log_info "Installing desktop workspace dependencies (includes Electron ~150MB, 1-3min)..." + ( cd "$INSTALL_DIR" && npm install ) || { + log_error "Desktop workspace npm install failed" + return 1 + } + log_success "Desktop workspace dependencies installed" + + # 2. Build. `npm run pack` = tsc + vite build + electron-builder --dir, + # producing an unpacked release/mac*/Hermes.app. We disable signing + # auto-discovery so electron-builder falls back to an ad-hoc signature + # instead of grabbing an unrelated Developer ID from the keychain; a + # real signed/notarized .dmg needs Apple credentials and is a separate + # release concern. + log_info "Building desktop app (this takes 1-3 minutes)..." + ( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack ) || { + log_error "Desktop app build failed" + log_info "Run manually: cd $desktop_dir && npm run pack" + return 1 + } + + local app="" + local cand + for cand in \ + "$desktop_dir/release/mac-arm64/Hermes.app" \ + "$desktop_dir/release/mac/Hermes.app"; do + if [ -d "$cand" ]; then + app="$cand" + break + fi + done + if [ -z "$app" ]; then + log_error "Desktop build completed but no Hermes.app was found under $desktop_dir/release/" + return 1 + fi + log_success "Desktop app built: $app" + + # `npm install` + `npm run pack` rewrite lockfiles; restore them so the + # checkout stays clean for the next `hermes update`. + restore_dirty_lockfiles "$INSTALL_DIR" +} + +# Each --stage runs in its own process, so (unlike the monolithic main() where +# clone_repo cd's once and later steps inherit it) a stage that operates on the +# checkout must cd into it explicitly. Without this, install_deps/setup_path run +# from the desktop app's cwd and resolve `.` / the venv against the wrong tree. +require_install_dir() { + if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then + log_error "Install directory not found: ${INSTALL_DIR:-<unset>}" + log_info "The 'repository' stage must run before this one." + return 1 + fi + cd "$INSTALL_DIR" +} + +# Desktop bootstrap stage protocol. Mirrors the Windows install.ps1 surface +# closely enough for the Electron bootstrap runner to show structured progress. +run_stage_body() { + local stage="$1" + + case "$stage" in + prerequisites) + print_banner + detect_os + resolve_install_layout + install_uv + check_python + check_git + check_node + check_network_prerequisites + install_system_packages + ;; + repository) + detect_os + resolve_install_layout + check_git + clone_repo + ;; + venv) + detect_os + resolve_install_layout + require_install_dir + install_uv + check_python + setup_venv + ;; + python-deps) + detect_os + resolve_install_layout + require_install_dir + install_uv + check_python + install_deps + ;; + node-deps) + detect_os + resolve_install_layout + require_install_dir + check_node + install_node_deps + ;; + path) + detect_os + resolve_install_layout + require_install_dir + setup_path + ;; + config) + detect_os + resolve_install_layout + require_install_dir + copy_config_templates + ;; + setup) + detect_os + resolve_install_layout + require_install_dir + run_setup_wizard + ;; + gateway) + detect_os + resolve_install_layout + require_install_dir + maybe_start_gateway + ;; + desktop) + detect_os + resolve_install_layout + require_install_dir + install_desktop + ;; + complete) + detect_os + resolve_install_layout + print_success + echo "git" > "$HERMES_HOME/.install_method" + ;; + *) + log_error "Unknown stage: $stage" + return 2 + ;; + esac +} + +run_stage_protocol() { + local stage="$1" + if [ -z "$stage" ]; then + log_error "--stage requires a stage name" + if [ "$JSON_OUTPUT" = true ]; then + emit_stage_json "" false false "missing stage name" + fi + return 2 + fi + + if [ "$NON_INTERACTIVE" = true ] && stage_needs_user_input "$stage"; then + log_info "Skipping $stage (non-interactive bootstrap)" + if [ "$JSON_OUTPUT" = true ]; then + emit_stage_json "$stage" true true + fi + return 0 + fi + + # Run the stage body in a subshell so a stage helper that calls `exit 1` + # on failure (clone_repo, install_deps, etc. were written for the monolithic + # flow) only exits the subshell — the parent still reaches the JSON result + # frame below. Without this, a failed --stage would terminate the process + # before emitting the frame and the Rust/Electron parser would see "no + # result frame" instead of a clean {ok:false} contract response. + set +e + ( run_stage_body "$stage" ) + local code=$? + set -e + + if [ "$JSON_OUTPUT" = true ]; then + if [ "$code" -eq 0 ]; then + emit_stage_json "$stage" true false + else + emit_stage_json "$stage" false false "exit code $code" + fi + fi + return "$code" +} + # ============================================================================ # Main # ============================================================================ @@ -2165,12 +2551,20 @@ main() { run_setup_wizard maybe_start_gateway + if [ "$INCLUDE_DESKTOP" = true ]; then + install_desktop + fi + print_success echo "git" > "$HERMES_HOME/.install_method" } -if [ -n "$ENSURE_DEPS" ]; then +if [ "$MANIFEST_MODE" = true ]; then + emit_manifest +elif [ -n "$STAGE_NAME" ]; then + run_stage_protocol "$STAGE_NAME" +elif [ -n "$ENSURE_DEPS" ]; then ensure_mode elif [ "$POSTINSTALL_MODE" = true ]; then postinstall_mode diff --git a/scripts/release.py b/scripts/release.py index 87d3ad2b1..44ffc2901 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1512,6 +1512,21 @@ def update_version_files(semver: str, calver_date: str): ) PYPROJECT_FILE.write_text(pyproject) + # Keep the desktop Electron app's package.json version in lockstep with the + # Python package version. The desktop About panel reads the live Hermes + # version at runtime, but app.getVersion()/packaging metadata still come + # from this field, so it must track pyproject to avoid drift. + desktop_pkg = REPO_ROOT / "apps" / "desktop" / "package.json" + if desktop_pkg.exists(): + pkg_text = desktop_pkg.read_text(encoding="utf-8") + pkg_text = re.sub( + r'("version"\s*:\s*)"[^"]+"', + rf'\g<1>"{semver}"', + pkg_text, + count=1, + ) + desktop_pkg.write_text(pkg_text, encoding="utf-8") + # Update ACP Registry manifest + npm launcher (must stay version-locked # with pyproject — enforced by tests/acp/test_registry_manifest.py). _update_acp_registry_versions(semver) diff --git a/scripts/whatsapp-bridge/package-lock.json b/scripts/whatsapp-bridge/package-lock.json index 2aaea4060..b662982cf 100644 --- a/scripts/whatsapp-bridge/package-lock.json +++ b/scripts/whatsapp-bridge/package-lock.json @@ -629,12 +629,13 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1" + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" } }, "node_modules/@protobufjs/float": { @@ -644,9 +645,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -1619,9 +1620,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", - "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -1629,14 +1630,14 @@ "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.1", + "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.3.2" + "long": "^5.0.0" }, "engines": { "node": ">=12.0.0" @@ -2116,9 +2117,9 @@ "license": "MIT" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/tests/agent/test_moonshot_schema.py b/tests/agent/test_moonshot_schema.py index 8ba508c5d..2ce2daa09 100644 --- a/tests/agent/test_moonshot_schema.py +++ b/tests/agent/test_moonshot_schema.py @@ -6,11 +6,6 @@ the JSON Schema ecosystem accepts: 1. Properties without ``type`` — Moonshot requires ``type`` on every node. 2. ``type`` at the parent of ``anyOf`` — Moonshot requires it only inside ``anyOf`` children. -3. ``$ref`` with sibling keywords — Moonshot expands the ref first and then - rejects ``description``/``type`` siblings on the same node. - (Ported from anomalyco/opencode#24730.) -4. Tuple-style ``items`` arrays — Moonshot requires a single item schema, - not positional ones. (Ported from anomalyco/opencode#24730.) These tests cover the repairs applied by ``agent/moonshot_schema.py``. """ @@ -185,164 +180,6 @@ class TestAnyOfParentType: assert db_type["enum"] == ["mysql", "postgresql"] # "" stripped by enum cleanup -class TestRefSiblingStripping: - """Rule 4: ``$ref`` nodes may not carry sibling keywords on Moonshot. - - Ported from anomalyco/opencode#24730. The real-world failure was MCP tools - whose generated schemas put a ``description`` on a ``$ref`` property so the - model would see the field's human-readable hint. The reference stays — the - referenced definition still owns the description (on the target node itself) - and still serves the model's context. - """ - - def test_description_sibling_stripped_from_ref(self): - params = { - "type": "object", - "properties": { - "variantOptions": { - "$ref": "#/$defs/VariantOptions", - "description": "Required. The variant options for generation.", - }, - }, - "$defs": { - "VariantOptions": { - "type": "object", - "properties": {}, - "description": "Configuration options.", - }, - }, - } - out = sanitize_moonshot_tool_parameters(params) - # Sibling stripped. - assert out["properties"]["variantOptions"] == {"$ref": "#/$defs/VariantOptions"} - # The target definition's own description is preserved — we only strip - # siblings ON the $ref node, not on the thing it points at. - assert out["$defs"]["VariantOptions"]["description"] == "Configuration options." - - def test_multiple_siblings_all_stripped(self): - params = { - "type": "object", - "properties": { - "p": { - "$ref": "#/$defs/T", - "type": "object", - "description": "x", - "default": {}, - "title": "P", - }, - }, - "$defs": {"T": {"type": "object"}}, - } - out = sanitize_moonshot_tool_parameters(params) - assert out["properties"]["p"] == {"$ref": "#/$defs/T"} - - def test_ref_without_siblings_unchanged(self): - params = { - "type": "object", - "properties": {"p": {"$ref": "#/$defs/T"}}, - "$defs": {"T": {"type": "object"}}, - } - out = sanitize_moonshot_tool_parameters(params) - assert out["properties"]["p"] == {"$ref": "#/$defs/T"} - - def test_ref_inside_anyof_children(self): - params = { - "type": "object", - "properties": { - "v": { - "anyOf": [ - {"$ref": "#/$defs/A", "description": "variant A"}, - {"type": "null"}, - ], - }, - }, - "$defs": {"A": {"type": "object"}}, - } - out = sanitize_moonshot_tool_parameters(params) - # Main's existing Rule 2 collapses anyOf-with-null down to the - # single non-null branch (Moonshot rejects null branches in anyOf - # outright). That branch was originally `{"$ref": ..., "description": ...}`; - # Rule 4 then strips the sibling, leaving exactly `{"$ref": "..."}`. - # The test name still applies — Rule 4 ran on the $ref branch — it - # just happens after the anyOf collapse on this input. - assert out["properties"]["v"] == {"$ref": "#/$defs/A"} - - -class TestTupleItems: - """Rule 5: tuple-style ``items`` arrays collapse to a single schema. - - Ported from anomalyco/opencode#24730. Moonshot's schema engine requires - ``items`` to be ONE schema object applied to every array element; tuple- - style positional item schemas are rejected. We collapse to the first - element's schema (which is the "closest" interpretation of positional → - single) and drop the rest. - """ - - def test_tuple_items_collapsed_to_first(self): - params = { - "type": "object", - "properties": { - "renderedSize": { - "type": "array", - "items": [{"type": "number"}, {"type": "number"}], - "minItems": 2, - "maxItems": 2, - }, - }, - } - out = sanitize_moonshot_tool_parameters(params) - assert out["properties"]["renderedSize"]["items"] == {"type": "number"} - # Sibling constraints are preserved — only the tuple shape is repaired. - assert out["properties"]["renderedSize"]["minItems"] == 2 - - def test_empty_tuple_items_becomes_empty_schema(self): - # Empty tuple collapses to ``{}``; the generic repair then fills a - # synthetic ``type`` because Moonshot requires ``type`` on every - # schema node. Either ``{}`` or ``{"type": "string"}`` is a valid - # final shape for Moonshot — both accept any string element — but we - # always go through ``_fill_missing_type`` so the result is fully - # well-formed without needing the consumer to patch it later. - params = { - "type": "object", - "properties": { - "things": {"type": "array", "items": []}, - }, - } - out = sanitize_moonshot_tool_parameters(params) - items = out["properties"]["things"]["items"] - # Must be a dict and must carry a ``type`` (the whole point of Rule 1). - assert isinstance(items, dict) - assert items.get("type") - - def test_tuple_items_first_element_is_repaired(self): - # The first element itself has a missing type — it should be filled. - params = { - "type": "object", - "properties": { - "pair": { - "type": "array", - "items": [{"description": "first"}, {"description": "second"}], - }, - }, - } - out = sanitize_moonshot_tool_parameters(params) - # Repaired to a single schema with a synthetic type. - assert out["properties"]["pair"]["items"] == { - "description": "first", - "type": "string", - } - - def test_single_schema_items_unchanged(self): - params = { - "type": "object", - "properties": { - "tags": {"type": "array", "items": {"type": "string"}}, - }, - } - out = sanitize_moonshot_tool_parameters(params) - assert out["properties"]["tags"]["items"] == {"type": "string"} - - class TestTopLevelGuarantees: """The returned top-level schema is always a well-formed object.""" diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 6516a25f8..e1f41aecc 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -2196,4 +2196,3 @@ class TestCloseCodeClassification: assert 4014 in fatal_codes assert 4001 in fatal_codes assert 4915 in fatal_codes - diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index a5e225b75..c2cf76d9f 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1793,162 +1793,3 @@ class TestSignalContentlessEnvelope: assert "event" in captured, "Normal message should NOT be skipped" assert captured["event"].text == "hello world" - - -# --------------------------------------------------------------------------- -# Envelope handling — group routing (legacy groupInfo vs modern groupV2) -# --------------------------------------------------------------------------- - -class TestSignalGroupV2Routing: - """Regression coverage for groupV2 envelope handling. - - signal-cli's JSON-RPC ``subscribeReceive`` envelope shape has drifted across - versions: some forward the underlying libsignal V2 envelope as - ``dataMessage.groupV2.id`` while older / normalized paths still use - ``dataMessage.groupInfo.groupId``. The adapter must read groupV2 first and - fall back to groupInfo so V2-only groups aren't misrouted as DMs. - - Ported from qwibitai/nanoclaw#1962 (V2 adapter improvements). - """ - - def _base_envelope(self, data_message: dict) -> dict: - return { - "envelope": { - "sourceNumber": "+15559998888", - "sourceUuid": "uuid-sender", - "sourceName": "Alice", - "timestamp": 1700000000000, - "dataMessage": data_message, - } - } - - @pytest.mark.asyncio - async def test_group_v2_id_routes_as_group(self, monkeypatch): - adapter = _make_signal_adapter(monkeypatch, group_allowed="*") - captured = [] - - async def _capture(event): - captured.append(event) - - adapter.handle_message = _capture - - env = self._base_envelope({ - "message": "hello v2", - "groupV2": {"id": "v2group=="}, - }) - - await adapter._handle_envelope(env) - - assert len(captured) == 1 - assert captured[0].source.chat_id == "group:v2group==" - assert captured[0].source.chat_type == "group" - assert captured[0].text == "hello v2" - - @pytest.mark.asyncio - async def test_legacy_group_info_still_works(self, monkeypatch): - adapter = _make_signal_adapter(monkeypatch, group_allowed="*") - captured = [] - - async def _capture(event): - captured.append(event) - - adapter.handle_message = _capture - - env = self._base_envelope({ - "message": "hello v1", - "groupInfo": {"groupId": "legacy=="}, - }) - - await adapter._handle_envelope(env) - - assert len(captured) == 1 - assert captured[0].source.chat_id == "group:legacy==" - assert captured[0].source.chat_type == "group" - - @pytest.mark.asyncio - async def test_group_v2_preferred_over_group_info(self, monkeypatch): - """When both fields are present, groupV2 wins — it's the authoritative V2 id.""" - adapter = _make_signal_adapter(monkeypatch, group_allowed="*") - captured = [] - - async def _capture(event): - captured.append(event) - - adapter.handle_message = _capture - - env = self._base_envelope({ - "message": "hello", - "groupV2": {"id": "v2=="}, - "groupInfo": {"groupId": "v1=="}, - }) - - await adapter._handle_envelope(env) - - assert len(captured) == 1 - assert captured[0].source.chat_id == "group:v2==" - - @pytest.mark.asyncio - async def test_no_group_fields_routes_as_dm(self, monkeypatch): - adapter = _make_signal_adapter(monkeypatch) - captured = [] - - async def _capture(event): - captured.append(event) - - adapter.handle_message = _capture - - env = self._base_envelope({"message": "direct message"}) - - await adapter._handle_envelope(env) - - assert len(captured) == 1 - assert captured[0].source.chat_type == "dm" - assert captured[0].source.chat_id == "+15559998888" - - @pytest.mark.asyncio - async def test_group_v2_respects_allowlist(self, monkeypatch): - """V2 group ids flow through the same SIGNAL_GROUP_ALLOWED_USERS filter.""" - adapter = _make_signal_adapter(monkeypatch, group_allowed="allowed-v2==") - captured = [] - - async def _capture(event): - captured.append(event) - - adapter.handle_message = _capture - - # Blocked group (not in allowlist) - await adapter._handle_envelope(self._base_envelope({ - "message": "blocked", - "groupV2": {"id": "blocked-v2=="}, - })) - assert len(captured) == 0 - - # Allowed group - await adapter._handle_envelope(self._base_envelope({ - "message": "allowed", - "groupV2": {"id": "allowed-v2=="}, - })) - assert len(captured) == 1 - assert captured[0].source.chat_id == "group:allowed-v2==" - - @pytest.mark.asyncio - async def test_malformed_group_fields_fall_through_to_dm(self, monkeypatch): - """Non-dict groupV2 / groupInfo shouldn't crash — treat as DM.""" - adapter = _make_signal_adapter(monkeypatch) - captured = [] - - async def _capture(event): - captured.append(event) - - adapter.handle_message = _capture - - env = self._base_envelope({ - "message": "malformed", - "groupV2": "not-a-dict", - "groupInfo": 42, - }) - - await adapter._handle_envelope(env) - - assert len(captured) == 1 - assert captured[0].source.chat_type == "dm" diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 830b0e14f..97618f448 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -9,6 +9,7 @@ We mock the slack modules at import time to avoid collection errors. """ import asyncio +import contextlib import os import sys from unittest.mock import AsyncMock, MagicMock, patch, call @@ -27,6 +28,7 @@ from gateway.platforms.base import ( # Mock the slack-bolt package if it's not installed # --------------------------------------------------------------------------- + def _ensure_slack_mock(): """Install mock slack modules so SlackAdapter can be imported.""" if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): @@ -44,7 +46,10 @@ def _ensure_slack_mock(): ("slack_bolt.async_app", slack_bolt.async_app), ("slack_bolt.adapter", slack_bolt.adapter), ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), - ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ( + "slack_bolt.adapter.socket_mode.async_handler", + slack_bolt.adapter.socket_mode.async_handler, + ), ("slack_sdk", slack_sdk), ("slack_sdk.web", slack_sdk.web), ("slack_sdk.web.async_client", slack_sdk.web.async_client), @@ -59,15 +64,42 @@ _ensure_slack_mock() # Patch SLACK_AVAILABLE before importing the adapter import gateway.platforms.slack as _slack_mod + _slack_mod.SLACK_AVAILABLE = True from gateway.platforms.slack import SlackAdapter # noqa: E402 +async def _pending_for_fake_task(): + # Stay pending so done-callbacks attached by the adapter (which would + # otherwise schedule a reconnect) don't fire during the test. The pytest + # event loop will cancel us at teardown, which the adapter's + # ``_on_socket_mode_task_done`` already treats as intentional shutdown. + await asyncio.Event().wait() + + +def _fake_create_task(coro): + """Test helper: consume the real coroutine and return a real awaitable Task. + + Returning an actual ``asyncio.Task`` (built via ``loop.create_task`` so the + ``asyncio.create_task`` patch doesn't recurse) keeps the substitute usable + by code that later cancels, awaits, or attaches ``add_done_callback`` — + so future tests that exercise ``disconnect()`` after patching + ``asyncio.create_task`` won't trip over a non-awaitable MagicMock. + """ + assert asyncio.iscoroutine(coro), ( + f"_fake_create_task expected a coroutine, got {type(coro).__name__}" + ) + coro.close() + loop = asyncio.get_event_loop() + return loop.create_task(_pending_for_fake_task()) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture() def adapter(): config = PlatformConfig(enabled=True, token="xoxb-fake-token") @@ -94,6 +126,7 @@ def _redirect_cache(tmp_path, monkeypatch): # TestSlashCommandSessionIsolation # --------------------------------------------------------------------------- + class TestSlashCommandSessionIsolation: @pytest.mark.asyncio async def test_channel_slash_command_uses_group_session_semantics(self, adapter): @@ -134,6 +167,7 @@ class TestSlashCommandSessionIsolation: # TestAppMentionHandler # --------------------------------------------------------------------------- + class TestAppMentionHandler: """Verify that the app_mention event handler is registered.""" @@ -152,37 +186,50 @@ class TestAppMentionHandler: def decorator(fn): registered_events.append(event_type) return fn + return decorator def mock_command(cmd): def decorator(fn): registered_commands.append(cmd) return fn + return decorator mock_app.event = mock_event mock_app.command = mock_command mock_app.client = AsyncMock() - mock_app.client.auth_test = AsyncMock(return_value={ - "user_id": "U_BOT", - "user": "testbot", - }) + mock_app.client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + } + ) # Mock AsyncWebClient so multi-workspace auth_test is awaitable mock_web_client = AsyncMock() - mock_web_client.auth_test = AsyncMock(return_value={ - "user_id": "U_BOT", - "user": "testbot", - "team_id": "T_FAKE", - "team": "FakeTeam", - }) + mock_web_client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + } + ) - with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ - patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("asyncio.create_task"): + socket_mode_handler = MagicMock() + socket_mode_handler.start_async = AsyncMock(return_value=None) + + with ( + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object( + _slack_mod, "AsyncSocketModeHandler", return_value=socket_mode_handler + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): asyncio.run(adapter.connect()) assert "message" in registered_events @@ -193,16 +240,17 @@ class TestAppMentionHandler: # covering every COMMAND_REGISTRY entry (e.g. /hermes, /btw, /stop, # /model, ...) so users get native-slash parity with Discord and # Telegram. Verify the regex matches the key expected slashes. - assert len(registered_commands) == 1, ( - f"expected 1 combined slash matcher, got {registered_commands!r}" - ) + assert ( + len(registered_commands) == 1 + ), f"expected 1 combined slash matcher, got {registered_commands!r}" slash_matcher = registered_commands[0] import re as _re + assert isinstance(slash_matcher, _re.Pattern) for expected in ("/hermes", "/btw", "/stop", "/model", "/help"): - assert slash_matcher.match(expected), ( - f"Slack slash regex does not match {expected}" - ) + assert slash_matcher.match( + expected + ), f"Slack slash regex does not match {expected}" class TestSlackConnectCleanup: @@ -217,12 +265,16 @@ class TestSlackConnectCleanup: mock_web_client = AsyncMock() mock_web_client.auth_test = AsyncMock(side_effect=RuntimeError("boom")) - with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ - patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("gateway.status.release_scoped_lock") as mock_release: + with ( + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object( + _slack_mod, "AsyncSocketModeHandler", return_value=MagicMock() + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("gateway.status.release_scoped_lock") as mock_release, + ): result = await adapter.connect() assert result is False @@ -247,31 +299,45 @@ class TestSlackConnectCleanup: adapter._handler = first_handler mock_app = MagicMock() + def _noop_decorator(event_type): - def decorator(fn): return fn + def decorator(fn): + return fn + return decorator + mock_app.event = _noop_decorator mock_app.command = _noop_decorator mock_app.action = _noop_decorator mock_app.client = AsyncMock() mock_web_client = AsyncMock() - mock_web_client.auth_test = AsyncMock(return_value={ - "user_id": "U_BOT", - "user": "testbot", - "team_id": "T_FAKE", - "team": "FakeTeam", - }) + mock_web_client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + } + ) second_handler = MagicMock() + # _start_socket_mode_handler awaits the result of start_async via + # asyncio.create_task — so the stub must return a real coroutine, not a + # bare MagicMock. + second_handler.start_async = AsyncMock(return_value=None) - with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ - patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=second_handler), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("gateway.status.release_scoped_lock"), \ - patch("asyncio.create_task"): + with ( + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object( + _slack_mod, "AsyncSocketModeHandler", return_value=second_handler + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("gateway.status.release_scoped_lock"), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): result = await adapter.connect() assert result is True @@ -279,10 +345,324 @@ class TestSlackConnectCleanup: assert adapter._handler is second_handler +# --------------------------------------------------------------------------- +# TestSlackSocketWatchdog +# --------------------------------------------------------------------------- + + +class TestSlackSocketWatchdog: + """End-to-end behavioural coverage for the Socket Mode watchdog/reconnect. + + These tests drive the adapter through a fake AsyncSocketModeHandler so we + can simulate Slack silently dropping the websocket (the original P0) and + assert the adapter heals itself without touching real network/Slack. + """ + + def _make_fake_handler_factory(self): + """Return ``(factory, instances)`` where each call records a handler.""" + instances: list = [] + + class FakeHandler: + def __init__(self, app, app_token, proxy=None): + self.app = app + self.app_token = app_token + self.proxy = proxy + self.client = MagicMock() + self.client.proxy = proxy + self.client.is_connected = lambda: True + self._start_event = asyncio.Event() + self.closed = False + self.start_calls = 0 + instances.append(self) + + async def start_async(self): + self.start_calls += 1 + await self._start_event.wait() + + async def close_async(self): + self.closed = True + self._start_event.set() + + return FakeHandler, instances + + def _patch_stack(self, fake_factory): + """Return a list of patcher context managers to keep active for the test.""" + mock_app = MagicMock() + + def _noop_decorator(_): + def decorator(fn): + return fn + + return decorator + + mock_app.event = _noop_decorator + mock_app.command = _noop_decorator + mock_app.action = _noop_decorator + mock_app.client = AsyncMock() + + mock_web_client = AsyncMock() + mock_web_client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + } + ) + + return [ + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object(_slack_mod, "AsyncSocketModeHandler", fake_factory), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("gateway.status.release_scoped_lock"), + ] + + async def _drain(self, iterations=10): + for _ in range(iterations): + await asyncio.sleep(0) + + @pytest.mark.asyncio + async def test_watchdog_reconnects_when_socket_task_dies_unexpectedly(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + assert len(instances) == 1 + + instances[0]._start_event.set() + await self._drain() + + for _ in range(40): + if len(instances) >= 2: + break + await asyncio.sleep(0.01) + + assert len(instances) >= 2, "watchdog/done_callback did not reconnect" + assert instances[0].closed is True + assert instances[-1].start_calls == 1 + assert adapter._handler is instances[-1] + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_watchdog_reconnects_when_transport_reports_disconnected(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + assert len(instances) == 1 + + instances[0].client.is_connected = lambda: False + + for _ in range(40): + if len(instances) >= 2: + break + await asyncio.sleep(0.01) + + assert len(instances) >= 2, "watchdog did not heal dead transport" + assert instances[0].closed is True + assert adapter._handler is instances[-1] + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_disconnect_stops_watchdog_and_does_not_reconnect(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + assert await adapter.connect() is True + assert len(instances) == 1 + + await adapter.disconnect() + + assert adapter._handler is None + assert adapter._socket_mode_task is None + assert adapter._socket_watchdog_task is None + assert instances[0].closed is True + + for _ in range(10): + await asyncio.sleep(0.01) + + assert len(instances) == 1, "watchdog kept reconnecting after disconnect" + + @pytest.mark.asyncio + async def test_watchdog_cancellation_does_not_respawn(self): + """Cancellation is the intentional-shutdown signal — no respawn allowed.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, _instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + first_watchdog = adapter._socket_watchdog_task + + first_watchdog.cancel() + for _ in range(20): + if first_watchdog.done(): + break + await asyncio.sleep(0.01) + + # Done-callback must treat cancel as a shutdown signal and + # leave the watchdog unattended (either cleared or unchanged + # to the same cancelled task — never a fresh respawn). + assert adapter._socket_watchdog_task is None or ( + adapter._socket_watchdog_task is first_watchdog + ) + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_watchdog_unexpected_exit_respawns_via_done_callback(self): + """A real exception out of the loop body must trigger a respawn.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, _instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + first_watchdog = adapter._socket_watchdog_task + assert first_watchdog is not None + + # Build a fake "crashed" task: a coroutine that raises so the + # done-callback observes a non-cancelled exit with exception. + async def _boom(): + raise RuntimeError("simulated watchdog crash") + + crashed = asyncio.create_task(_boom()) + # Wait for it to actually complete with the exception. + for _ in range(20): + if crashed.done(): + break + await asyncio.sleep(0.01) + assert crashed.done() and crashed.exception() is not None + + # Pretend this crashed task is the current watchdog and drive + # the done-callback directly — this is the exact signal the + # event loop fires when the real watchdog blows up. + adapter._socket_watchdog_task = crashed + adapter._on_socket_watchdog_done(crashed) + + replacement = adapter._socket_watchdog_task + assert replacement is not None + assert replacement is not crashed + assert not replacement.done() + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_connect_replaces_prior_watchdog_atomically(self): + """A reconnect must not leave the adapter without a watchdog.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + first_watchdog = adapter._socket_watchdog_task + assert first_watchdog is not None + + # Second connect() must cancel the prior watchdog and install + # a brand new one — never observe a window with no watchdog. + assert await adapter.connect() is True + second_watchdog = adapter._socket_watchdog_task + assert second_watchdog is not None + assert second_watchdog is not first_watchdog + assert first_watchdog.done() + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_reconnect_refreshes_multi_workspace_state(self): + """A reconnect that rotates the primary token must drop stale state.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 9999 + factory, _instances = self._make_fake_handler_factory() + + # Pre-seed stale multi-workspace state as if a prior connect had run. + adapter._bot_user_id = "U_OLD_BOT" + adapter._team_clients = {"T_OLD": MagicMock(name="old-client")} + adapter._team_bot_user_ids = {"T_OLD": "U_OLD_BOT"} + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + + # State must reflect the fresh auth, not the stale seed. + assert adapter._bot_user_id == "U_BOT" + assert "T_OLD" not in adapter._team_clients + assert "T_OLD" not in adapter._team_bot_user_ids + assert "T_FAKE" in adapter._team_clients + assert adapter._team_bot_user_ids["T_FAKE"] == "U_BOT" + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_reconnect_lock_prevents_concurrent_reconnects(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 9999 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + baseline = len(instances) + + await asyncio.gather( + adapter._restart_socket_mode("watchdog"), + adapter._restart_socket_mode("done-callback"), + ) + + new_handlers = len(instances) - baseline + assert new_handlers >= 1 + assert ( + new_handlers <= 2 + ), f"reconnect lock failed: {new_handlers} new handlers" + finally: + await adapter.disconnect() + + # --------------------------------------------------------------------------- # TestSlackProxyBehavior # --------------------------------------------------------------------------- + class TestSlackProxyBehavior: def test_no_proxy_helper_matches_slack_hosts(self): assert is_host_excluded_by_no_proxy("slack.com", "localhost,.slack.com") @@ -291,18 +671,34 @@ class TestSlackProxyBehavior: assert not is_host_excluded_by_no_proxy("slack.com", "localhost,.internal.corp") def test_resolve_slack_proxy_url_ignores_unsupported_proxy_schemes(self): - with patch.object(_slack_mod, "resolve_proxy_url", return_value="socks5://proxy.example.com:1080"): + with patch.object( + _slack_mod, + "resolve_proxy_url", + return_value="socks5://proxy.example.com:1080", + ): assert _slack_mod._resolve_slack_proxy_url() is None def test_resolve_slack_proxy_url_checks_all_slack_hosts(self): - with patch.object(_slack_mod, "resolve_proxy_url", return_value="http://proxy.example.com:3128"), \ - patch.object(_slack_mod, "is_host_excluded_by_no_proxy", side_effect=lambda host: host == "wss-primary.slack.com") as excluded: + with ( + patch.object( + _slack_mod, + "resolve_proxy_url", + return_value="http://proxy.example.com:3128", + ), + patch.object( + _slack_mod, + "is_host_excluded_by_no_proxy", + side_effect=lambda host: host == "wss-primary.slack.com", + ) as excluded, + ): assert _slack_mod._resolve_slack_proxy_url() is None - excluded.assert_has_calls([ - call("slack.com"), - call("files.slack.com"), - call("wss-primary.slack.com"), - ]) + excluded.assert_has_calls( + [ + call("slack.com"), + call("files.slack.com"), + call("wss-primary.slack.com"), + ] + ) @pytest.mark.asyncio async def test_connect_uses_proxy_when_not_bypassed(self): @@ -314,12 +710,14 @@ class TestSlackProxyBehavior: self.token = token self.proxy = "constructor-default" suffix = token.split("-")[-1] - self.auth_test = AsyncMock(return_value={ - "team_id": f"T_{suffix}", - "user_id": f"U_{suffix}", - "user": f"bot-{suffix}", - "team": f"Team {suffix}", - }) + self.auth_test = AsyncMock( + return_value={ + "team_id": f"T_{suffix}", + "user_id": f"U_{suffix}", + "user": f"bot-{suffix}", + "team": f"Team {suffix}", + } + ) created_clients.append(self) class FakeApp: @@ -362,7 +760,7 @@ class TestSlackProxyBehavior: self.proxy = proxy self.client = MagicMock(proxy="constructor-default") - def start_async(self): + async def start_async(self): return None async def close_async(self): @@ -371,18 +769,27 @@ class TestSlackProxyBehavior: config = PlatformConfig(enabled=True, token="xoxb-primary,xoxb-secondary") adapter = SlackAdapter(config) - with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \ - patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \ - patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value="http://proxy.example.com:3128"), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")): + with ( + patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), + patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), + patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), + patch.object( + _slack_mod, + "_resolve_slack_proxy_url", + return_value="http://proxy.example.com:3128", + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): result = await adapter.connect() assert result is True assert created_apps[0].client.proxy == "http://proxy.example.com:3128" - assert all(client.proxy == "http://proxy.example.com:3128" for client in created_clients) + assert all( + client.proxy == "http://proxy.example.com:3128" + for client in created_clients + ) assert adapter._handler is not None assert adapter._handler.proxy == "http://proxy.example.com:3128" assert adapter._handler.client.proxy == "http://proxy.example.com:3128" @@ -397,12 +804,14 @@ class TestSlackProxyBehavior: self.token = token self.proxy = "constructor-default" suffix = token.split("-")[-1] - self.auth_test = AsyncMock(return_value={ - "team_id": f"T_{suffix}", - "user_id": f"U_{suffix}", - "user": f"bot-{suffix}", - "team": f"Team {suffix}", - }) + self.auth_test = AsyncMock( + return_value={ + "team_id": f"T_{suffix}", + "user_id": f"U_{suffix}", + "user": f"bot-{suffix}", + "team": f"Team {suffix}", + } + ) created_clients.append(self) class FakeApp: @@ -445,7 +854,7 @@ class TestSlackProxyBehavior: self.proxy = proxy self.client = MagicMock(proxy="constructor-default") - def start_async(self): + async def start_async(self): return None async def close_async(self): @@ -454,13 +863,15 @@ class TestSlackProxyBehavior: config = PlatformConfig(enabled=True, token="xoxb-primary") adapter = SlackAdapter(config) - with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \ - patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \ - patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value=None), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")): + with ( + patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), + patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), + patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), + patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value=None), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): result = await adapter.connect() assert result is True @@ -475,6 +886,7 @@ class TestSlackProxyBehavior: # TestSendDocument # --------------------------------------------------------------------------- + class TestSendDocument: @pytest.mark.asyncio async def test_send_document_success(self, adapter, tmp_path): @@ -571,7 +983,9 @@ class TestSendDocument: assert call_kwargs["thread_ts"] == "1234567890.123456" @pytest.mark.asyncio - async def test_send_document_thread_upload_marks_bot_participation(self, adapter, tmp_path): + async def test_send_document_thread_upload_marks_bot_participation( + self, adapter, tmp_path + ): test_file = tmp_path / "notes.txt" test_file.write_bytes(b"some notes") @@ -586,7 +1000,9 @@ class TestSendDocument: assert "1234567890.123456" in adapter._bot_message_ts @pytest.mark.asyncio - async def test_send_document_retries_transient_upload_error(self, adapter, tmp_path): + async def test_send_document_retries_transient_upload_error( + self, adapter, tmp_path + ): test_file = tmp_path / "notes.txt" test_file.write_bytes(b"some notes") @@ -608,7 +1024,9 @@ class TestSendDocument: class TestSendPrivateNotice: @pytest.mark.asyncio async def test_send_private_notice_uses_ephemeral_api(self, adapter): - adapter._app.client.chat_postEphemeral = AsyncMock(return_value={"message_ts": "123.456"}) + adapter._app.client.chat_postEphemeral = AsyncMock( + return_value={"message_ts": "123.456"} + ) result = await adapter.send_private_notice( chat_id="C123", @@ -631,6 +1049,7 @@ class TestSendPrivateNotice: # TestSendVideo # --------------------------------------------------------------------------- + class TestSendVideo: @pytest.mark.asyncio async def test_send_video_success(self, adapter, tmp_path): @@ -782,7 +1201,9 @@ class TestBangPrefixCommands: class TestIncomingDocumentHandling: - def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None): + def _make_event( + self, files=None, text="hello", channel_type="im", blocks=None, attachments=None + ): """Build a mock Slack message event with file attachments.""" return { "text": text, @@ -800,14 +1221,20 @@ class TestIncomingDocumentHandling: """A PDF attachment should be downloaded, cached, and set as DOCUMENT type.""" pdf_bytes = b"%PDF-1.4 fake content" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = pdf_bytes - event = self._make_event(files=[{ - "mimetype": "application/pdf", - "name": "report.pdf", - "url_private_download": "https://files.slack.com/report.pdf", - "size": len(pdf_bytes), - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/pdf", + "name": "report.pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "size": len(pdf_bytes), + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -821,16 +1248,20 @@ class TestIncomingDocumentHandling: """A .txt file under 100KB should have its content injected into event text.""" content = b"Hello from a text file" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content event = self._make_event( text="summarize this", - files=[{ - "mimetype": "text/plain", - "name": "notes.txt", - "url_private_download": "https://files.slack.com/notes.txt", - "size": len(content), - }], + files=[ + { + "mimetype": "text/plain", + "name": "notes.txt", + "url_private_download": "https://files.slack.com/notes.txt", + "size": len(content), + } + ], ) await adapter._handle_slack_message(event) @@ -844,14 +1275,21 @@ class TestIncomingDocumentHandling: """A .md file under 100KB should have its content injected.""" content = b"# Title\nSome markdown content" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content - event = self._make_event(files=[{ - "mimetype": "text/markdown", - "name": "readme.md", - "url_private_download": "https://files.slack.com/readme.md", - "size": len(content), - }], text="") + event = self._make_event( + files=[ + { + "mimetype": "text/markdown", + "name": "readme.md", + "url_private_download": "https://files.slack.com/readme.md", + "size": len(content), + } + ], + text="", + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -862,20 +1300,24 @@ class TestIncomingDocumentHandling: """A .json snippet should be treated as a text document and injected.""" content = b'{"hello": "world", "count": 2}' - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content event = self._make_event( text="can you parse this", - files=[{ - "mimetype": "text/plain", - "name": "zapfile.json", - "filetype": "json", - "pretty_type": "JSON", - "mode": "snippet", - "editable": True, - "url_private_download": "https://files.slack.com/zapfile.json", - "size": len(content), - }], + files=[ + { + "mimetype": "text/plain", + "name": "zapfile.json", + "filetype": "json", + "pretty_type": "JSON", + "mode": "snippet", + "editable": True, + "url_private_download": "https://files.slack.com/zapfile.json", + "size": len(content), + } + ], ) await adapter._handle_slack_message(event) @@ -883,23 +1325,30 @@ class TestIncomingDocumentHandling: assert msg_event.message_type == MessageType.DOCUMENT assert len(msg_event.media_urls) == 1 assert msg_event.media_types == ["application/json"] - assert '[Content of zapfile.json]' in msg_event.text + assert "[Content of zapfile.json]" in msg_event.text assert '"hello": "world"' in msg_event.text - assert 'can you parse this' in msg_event.text + assert "can you parse this" in msg_event.text @pytest.mark.asyncio async def test_large_txt_not_injected(self, adapter): """A .txt file over 100KB should be cached but NOT injected.""" content = b"x" * (200 * 1024) - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content - event = self._make_event(files=[{ - "mimetype": "text/plain", - "name": "big.txt", - "url_private_download": "https://files.slack.com/big.txt", - "size": len(content), - }], text="") + event = self._make_event( + files=[ + { + "mimetype": "text/plain", + "name": "big.txt", + "url_private_download": "https://files.slack.com/big.txt", + "size": len(content), + } + ], + text="", + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -909,14 +1358,20 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_zip_file_cached(self, adapter): """A .zip file should be cached as a supported document.""" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = b"PK\x03\x04zip" - event = self._make_event(files=[{ - "mimetype": "application/zip", - "name": "archive.zip", - "url_private_download": "https://files.slack.com/archive.zip", - "size": 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/zip", + "name": "archive.zip", + "url_private_download": "https://files.slack.com/archive.zip", + "size": 1024, + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -927,12 +1382,16 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_oversized_document_skipped(self, adapter): """A document over 20MB should be skipped.""" - event = self._make_event(files=[{ - "mimetype": "application/pdf", - "name": "huge.pdf", - "url_private_download": "https://files.slack.com/huge.pdf", - "size": 25 * 1024 * 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/pdf", + "name": "huge.pdf", + "url_private_download": "https://files.slack.com/huge.pdf", + "size": 25 * 1024 * 1024, + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -941,14 +1400,20 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_document_download_error_handled(self, adapter): """If document download fails, handler should not crash.""" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.side_effect = RuntimeError("download failed") - event = self._make_event(files=[{ - "mimetype": "application/pdf", - "name": "report.pdf", - "url_private_download": "https://files.slack.com/report.pdf", - "size": 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/pdf", + "name": "report.pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "size": 1024, + } + ] + ) await adapter._handle_slack_message(event) # Handler should still be called (the exception is caught) @@ -957,14 +1422,20 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_image_still_handled(self, adapter): """Image attachments should still go through the image path, not document.""" - with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file", new_callable=AsyncMock + ) as dl: dl.return_value = "/tmp/cached_image.jpg" - event = self._make_event(files=[{ - "mimetype": "image/jpeg", - "name": "photo.jpg", - "url_private_download": "https://files.slack.com/photo.jpg", - "size": 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "image/jpeg", + "name": "photo.jpg", + "url_private_download": "https://files.slack.com/photo.jpg", + "size": 1024, + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -979,18 +1450,26 @@ class TestIncomingDocumentHandling: runs only when the download actually fails. """ import httpx + req = httpx.Request("GET", "https://files.slack.com/photo.jpg") resp = httpx.Response(403, request=req) - with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file", new_callable=AsyncMock + ) as dl: dl.side_effect = httpx.HTTPStatusError("403", request=req, response=resp) - event = self._make_event(text="what's in this?", files=[{ - "id": "F123", - "mimetype": "image/jpeg", - "name": "photo.jpg", - "url_private_download": "https://files.slack.com/photo.jpg", - "size": 1024, - }]) + event = self._make_event( + text="what's in this?", + files=[ + { + "id": "F123", + "mimetype": "image/jpeg", + "name": "photo.jpg", + "url_private_download": "https://files.slack.com/photo.jpg", + "size": 1024, + } + ], + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -1039,7 +1518,9 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "Quoted line"}], + "elements": [ + {"type": "text", "text": "Quoted line"} + ], } ], }, @@ -1049,11 +1530,15 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "First bullet"}], + "elements": [ + {"type": "text", "text": "First bullet"} + ], }, { "type": "rich_text_section", - "elements": [{"type": "text", "text": "Second bullet"}], + "elements": [ + {"type": "text", "text": "Second bullet"} + ], }, ], }, @@ -1071,7 +1556,9 @@ class TestIncomingDocumentHandling: assert "• Second bullet" in msg_event.text @pytest.mark.asyncio - async def test_attachments_unfurl_text_is_appended_even_when_url_is_in_message(self, adapter): + async def test_attachments_unfurl_text_is_appended_even_when_url_is_in_message( + self, adapter + ): """Shared URLs should still expose unfurl preview text to the agent.""" event = self._make_event( text="Look at this doc https://example.com/spec", @@ -1113,7 +1600,9 @@ class TestIncomingDocumentHandling: assert msg_event.text == "https://example.com/thread" @pytest.mark.asyncio - async def test_channel_routing_ignores_bot_mentions_inside_block_text(self, adapter): + async def test_channel_routing_ignores_bot_mentions_inside_block_text( + self, adapter + ): """Block-extracted text with a bot mention must not satisfy mention gating in channels — routing decisions use the original user text so quoted/forwarded content can't trick the bot into responding.""" @@ -1129,7 +1618,12 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "Contains <@U_BOT> in quoted text"}], + "elements": [ + { + "type": "text", + "text": "Contains <@U_BOT> in quoted text", + } + ], } ], } @@ -1143,7 +1637,9 @@ class TestIncomingDocumentHandling: adapter.handle_message.assert_not_called() @pytest.mark.asyncio - async def test_quoted_slash_command_text_does_not_change_message_type(self, adapter): + async def test_quoted_slash_command_text_does_not_change_message_type( + self, adapter + ): """Quoted slash-like content should not convert a normal message into a command.""" event = self._make_event( text="", @@ -1156,7 +1652,9 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "/deploy now"}], + "elements": [ + {"type": "text", "text": "/deploy now"} + ], } ], } @@ -1176,6 +1674,7 @@ class TestIncomingDocumentHandling: # TestMessageRouting # --------------------------------------------------------------------------- + class TestMessageRouting: @pytest.mark.asyncio async def test_dm_processed_without_mention(self, adapter): @@ -1295,7 +1794,9 @@ class TestSendTyping: await adapter.stop_typing("C123", metadata={"thread_id": "parent_ts"}) - assert adapter._app.client.assistant_threads_setStatus.call_args_list[1] == call( + assert adapter._app.client.assistant_threads_setStatus.call_args_list[ + 1 + ] == call( channel_id="C123", thread_ts="parent_ts", status="", @@ -1328,7 +1829,9 @@ class TestSendTyping: @pytest.mark.asyncio async def test_send_clears_status_after_final_post(self, adapter): - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) adapter._app.client.assistant_threads_setStatus = AsyncMock() adapter._active_status_threads["C123"] = "parent_ts" @@ -1527,7 +2030,9 @@ class TestFormatMessage: def test_link_with_parentheses_in_url(self, adapter): """Wikipedia-style URL with balanced parens is not truncated.""" - result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + result = adapter.format_message( + "[Foo](https://en.wikipedia.org/wiki/Foo_(bar))" + ) assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" def test_link_with_multiple_paren_pairs(self, adapter): @@ -1542,7 +2047,9 @@ class TestFormatMessage: def test_link_with_angle_brackets_and_parens(self, adapter): """Angle-bracket URL with parens (CommonMark syntax).""" - result = adapter.format_message("[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)") + result = adapter.format_message( + "[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)" + ) assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" def test_escaping_is_idempotent(self, adapter): @@ -1564,7 +2071,10 @@ class TestFormatMessage: def test_subteam_mention_preserved(self, adapter): """<!subteam^ID> user group mention passes through unchanged.""" - assert adapter.format_message("Paging <!subteam^S12345>") == "Paging <!subteam^S12345>" + assert ( + adapter.format_message("Paging <!subteam^S12345>") + == "Paging <!subteam^S12345>" + ) def test_date_formatting_preserved(self, adapter): """<!date^...> formatting token passes through unchanged.""" @@ -1768,7 +2278,9 @@ class TestEditMessageStreamingPipeline: async def test_edit_message_formats_url_with_parens(self, adapter): """Wikipedia-style URL with parens survives edit pipeline.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) - await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + await adapter.edit_message( + "C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))" + ) kwargs = adapter._app.client.chat_update.call_args.kwargs assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"] @@ -1800,7 +2312,9 @@ class TestReactions: @pytest.mark.asyncio async def test_add_reaction_handles_error(self, adapter): - adapter._app.client.reactions_add = AsyncMock(side_effect=Exception("already_reacted")) + adapter._app.client.reactions_add = AsyncMock( + side_effect=Exception("already_reacted") + ) result = await adapter._add_reaction("C123", "ts1", "eyes") assert result is False @@ -1815,9 +2329,9 @@ class TestReactions: """Reactions should be bracketed around actual processing via hooks.""" adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) event = { "text": "hello", @@ -1832,7 +2346,9 @@ class TestReactions: assert "1234567890.000001" in adapter._reacting_message_ids # Simulate the base class calling on_processing_start - from gateway.platforms.base import MessageType, SessionSource + from gateway.platforms.base import MessageEvent, MessageType, SessionSource + from gateway.config import Platform + source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -1853,6 +2369,7 @@ class TestReactions: # Simulate the base class calling on_processing_complete from gateway.platforms.base import ProcessingOutcome + await adapter.on_processing_complete(msg_event, ProcessingOutcome.SUCCESS) add_calls = adapter._app.client.reactions_add.call_args_list @@ -1871,7 +2388,14 @@ class TestReactions: adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - from gateway.platforms.base import MessageType, SessionSource, ProcessingOutcome + from gateway.platforms.base import ( + MessageEvent, + MessageType, + SessionSource, + ProcessingOutcome, + ) + from gateway.config import Platform + source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -1899,9 +2423,9 @@ class TestReactions: """Non-DM, non-mention messages should not get reactions.""" adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) event = { "text": "hello", @@ -1923,9 +2447,9 @@ class TestReactions: monkeypatch.setenv("SLACK_REACTIONS", "false") adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) event = { "text": "hello", @@ -1940,7 +2464,14 @@ class TestReactions: assert "1234567890.000004" not in adapter._reacting_message_ids # Hooks should also be no-ops when disabled - from gateway.platforms.base import MessageType, SessionSource, ProcessingOutcome + from gateway.platforms.base import ( + MessageEvent, + MessageType, + SessionSource, + ProcessingOutcome, + ) + from gateway.config import Platform + source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -2090,9 +2621,7 @@ class TestThreadReplyHandling: adapter_with_session_store.handle_message.assert_not_called() @pytest.mark.asyncio - async def test_no_session_store_ignores_thread_replies( - self, adapter - ): + async def test_no_session_store_ignores_thread_replies(self, adapter): """If no session store is attached, thread replies without mention should be ignored.""" # adapter fixture has no session store attached event = { @@ -2140,7 +2669,9 @@ class TestAssistantThreadLifecycle: return a @pytest.mark.asyncio - async def test_lifecycle_event_seeds_session_store(self, assistant_adapter, mock_session_store): + async def test_lifecycle_event_seeds_session_store( + self, assistant_adapter, mock_session_store + ): event = { "type": "assistant_thread_started", "team_id": "T_TEAM", @@ -2154,7 +2685,10 @@ class TestAssistantThreadLifecycle: await assistant_adapter._handle_assistant_thread_lifecycle_event(event) - assert assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] == "U_USER" + assert ( + assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] + == "U_USER" + ) mock_session_store.get_or_create_session.assert_called_once() source = mock_session_store.get_or_create_session.call_args[0][0] assert source.chat_id == "D123" @@ -2164,16 +2698,18 @@ class TestAssistantThreadLifecycle: assert source.chat_topic == "C_ORIGIN" @pytest.mark.asyncio - async def test_message_uses_cached_assistant_thread_identity(self, assistant_adapter): + async def test_message_uses_cached_assistant_thread_identity( + self, assistant_adapter + ): assistant_adapter._assistant_threads[("D123", "171.000")] = { "channel_id": "D123", "thread_ts": "171.000", "user_id": "U_USER", "team_id": "T_TEAM", } - assistant_adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + assistant_adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) assistant_adapter._app.client.reactions_add = AsyncMock() assistant_adapter._app.client.reactions_remove = AsyncMock() @@ -2198,19 +2734,23 @@ class TestAssistantThreadLifecycle: assistant_adapter._ASSISTANT_THREADS_MAX = 10 # Fill to the limit for i in range(10): - assistant_adapter._cache_assistant_thread_metadata({ - "channel_id": f"D{i}", - "thread_ts": f"{i}.000", - "user_id": f"U{i}", - }) + assistant_adapter._cache_assistant_thread_metadata( + { + "channel_id": f"D{i}", + "thread_ts": f"{i}.000", + "user_id": f"U{i}", + } + ) assert len(assistant_adapter._assistant_threads) == 10 # Adding one more should trigger eviction (down to max // 2 = 5) - assistant_adapter._cache_assistant_thread_metadata({ - "channel_id": "D999", - "thread_ts": "999.000", - "user_id": "U999", - }) + assistant_adapter._cache_assistant_thread_metadata( + { + "channel_id": "D999", + "thread_ts": "999.000", + "user_id": "U999", + } + ) assert len(assistant_adapter._assistant_threads) <= 10 # The newest entry must survive eviction assert ("D999", "999.000") in assistant_adapter._assistant_threads @@ -2226,25 +2766,29 @@ class TestUserNameResolution: @pytest.mark.asyncio async def test_resolves_display_name(self, adapter): - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler", "real_name": "Tyler B"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={ + "user": {"profile": {"display_name": "Tyler", "real_name": "Tyler B"}} + } + ) name = await adapter._resolve_user_name("U123") assert name == "Tyler" @pytest.mark.asyncio async def test_falls_back_to_real_name(self, adapter): - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "", "real_name": "Tyler B"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={ + "user": {"profile": {"display_name": "", "real_name": "Tyler B"}} + } + ) name = await adapter._resolve_user_name("U123") assert name == "Tyler B" @pytest.mark.asyncio async def test_caches_result(self, adapter): - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) await adapter._resolve_user_name("U123") await adapter._resolve_user_name("U123") # Only one API call despite two lookups @@ -2252,16 +2796,18 @@ class TestUserNameResolution: @pytest.mark.asyncio async def test_handles_api_error(self, adapter): - adapter._app.client.users_info = AsyncMock(side_effect=Exception("rate limited")) + adapter._app.client.users_info = AsyncMock( + side_effect=Exception("rate limited") + ) name = await adapter._resolve_user_name("U123") assert name == "U123" # Falls back to user_id @pytest.mark.asyncio async def test_user_name_in_message_source(self, adapter): """Message source should include resolved user name.""" - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() @@ -2412,9 +2958,7 @@ class TestMessageSplitting: async def test_long_message_split_into_chunks(self, adapter): """Messages over MAX_MESSAGE_LENGTH should be split.""" long_text = "x" * 45000 # Over Slack's 40k API limit - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", long_text) # Should have been called multiple times assert adapter._app.client.chat_postMessage.call_count >= 2 @@ -2422,9 +2966,7 @@ class TestMessageSplitting: @pytest.mark.asyncio async def test_short_message_single_send(self, adapter): """Short messages should be sent in one call.""" - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "hello world") assert adapter._app.client.chat_postMessage.call_count == 1 @@ -2481,9 +3023,7 @@ class TestReplyBroadcast: @pytest.mark.asyncio async def test_broadcast_disabled_by_default(self, adapter): - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"}) kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "reply_broadcast" not in kwargs @@ -2491,9 +3031,7 @@ class TestReplyBroadcast: @pytest.mark.asyncio async def test_broadcast_enabled_via_config(self, adapter): adapter.config.extra["reply_broadcast"] = True - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"}) kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert kwargs.get("reply_broadcast") is True @@ -2503,6 +3041,7 @@ class TestReplyBroadcast: # TestFallbackPreservesThreadContext # --------------------------------------------------------------------------- + class TestFallbackPreservesThreadContext: """Bug fix: file upload fallbacks lost thread context (metadata) when calling super() without metadata, causing replies to appear outside @@ -2516,9 +3055,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) metadata = {"thread_id": "parent_ts_123"} await adapter.send_image_file( @@ -2539,9 +3076,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) metadata = {"thread_id": "parent_ts_456"} await adapter.send_video( @@ -2561,9 +3096,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) metadata = {"thread_id": "parent_ts_789"} await adapter.send_document( @@ -2584,9 +3117,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) await adapter.send_image_file( chat_id="C123", @@ -2602,6 +3133,7 @@ class TestFallbackPreservesThreadContext: # TestSendImageSSRFGuards # --------------------------------------------------------------------------- + class TestSendImageSSRFGuards: """send_image should reject redirects that land on private/internal hosts.""" @@ -2624,7 +3156,9 @@ class TestSendImageSSRFGuards: mock_client.get = AsyncMock(side_effect=fake_get) adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) def fake_async_client(*args, **kwargs): client_kwargs.update(kwargs) @@ -2671,7 +3205,9 @@ class TestSendImageSSRFGuards: mock_client.get = AsyncMock(side_effect=fake_get) adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) def fake_async_client(*args, **kwargs): client_kwargs.update(kwargs) @@ -2699,6 +3235,7 @@ class TestSendImageSSRFGuards: # TestProgressMessageThread # --------------------------------------------------------------------------- + class TestProgressMessageThread: """Verify that progress messages go to the correct thread. @@ -2722,10 +3259,14 @@ class TestProgressMessageThread: } captured_events = [] - adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) + adapter.handle_message = AsyncMock( + side_effect=lambda e: captured_events.append(e) + ) # Patch _resolve_user_name to avoid async Slack API call - with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): + with patch.object( + adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser") + ): await adapter._handle_slack_message(event) assert len(captured_events) == 1 @@ -2748,7 +3289,9 @@ class TestProgressMessageThread: # Verify that the Slack send() method correctly threads a message # when metadata contains thread_id equal to the original ts - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) result = await adapter.send( chat_id="D_DM", content="⚙️ working...", @@ -2775,9 +3318,13 @@ class TestProgressMessageThread: } captured_events = [] - adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) + adapter.handle_message = AsyncMock( + side_effect=lambda e: captured_events.append(e) + ) - with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): + with patch.object( + adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser") + ): await adapter._handle_slack_message(event) assert len(captured_events) == 1 @@ -2803,9 +3350,13 @@ class TestProgressMessageThread: } captured_events = [] - adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) + adapter.handle_message = AsyncMock( + side_effect=lambda e: captured_events.append(e) + ) - with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): + with patch.object( + adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser") + ): await adapter._handle_slack_message(event) assert len(captured_events) == 1 @@ -2833,16 +3384,18 @@ class TestSlackReplyToText: adapter._team_bot_user_ids = {} # Mock conversations_replies to return a bot-posted parent - adapter._app.client.conversations_replies = AsyncMock(return_value={ - "messages": [ - { - "ts": "1000.0", - "bot_id": "B_CRON", - "text": "メール要約: 新着メール3件あります", - }, - {"ts": "1000.5", "user": "U_USER", "text": "詳細を教えて"}, - ] - }) + adapter._app.client.conversations_replies = AsyncMock( + return_value={ + "messages": [ + { + "ts": "1000.0", + "bot_id": "B_CRON", + "text": "メール要約: 新着メール3件あります", + }, + {"ts": "1000.5", "user": "U_USER", "text": "詳細を教えて"}, + ] + } + ) # Use a DM so mention-gating doesn't short-circuit the handler. event = { @@ -2859,9 +3412,9 @@ class TestSlackReplyToText: ): await adapter._handle_slack_message(event) - assert adapter.handle_message.call_args is not None, ( - "handle_message must be invoked for thread-reply DM" - ) + assert ( + adapter.handle_message.call_args is not None + ), "handle_message must be invoked for thread-reply DM" msg_event = adapter.handle_message.call_args[0][0] assert msg_event.reply_to_message_id == "1000.0" # The critical assertion: parent text is exposed as reply_to_text so the @@ -2937,6 +3490,7 @@ class TestSlashEphemeralAck: async def test_pop_slash_context_returns_and_removes(self, adapter): """_pop_slash_context returns the context and removes it.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/test", "ts": time.monotonic(), @@ -2958,6 +3512,7 @@ class TestSlashEphemeralAck: async def test_pop_slash_context_discards_stale_entries(self, adapter): """Stale contexts older than TTL are cleaned up.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/stale", "ts": time.monotonic() - adapter._SLASH_CTX_TTL - 1, @@ -2971,6 +3526,7 @@ class TestSlashEphemeralAck: async def test_send_uses_response_url_when_context_exists(self, adapter): """send() should POST to response_url for slash command replies.""" import time + adapter._slash_command_contexts[("C_SLASH", "U_SLASH")] = { "response_url": "https://hooks.slack.com/commands/T123/456/abc", "ts": time.monotonic(), @@ -2986,7 +3542,9 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch( + "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + ): result = await adapter.send("C_SLASH", "Queued for the next turn.") assert result.success is True @@ -3017,6 +3575,7 @@ class TestSlashEphemeralAck: async def test_send_slash_ephemeral_fallback_on_post_failure(self, adapter): """_send_slash_ephemeral returns success=True even if POST fails.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/commands/bad", "ts": time.monotonic(), @@ -3033,7 +3592,9 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch( + "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + ): result = await adapter.send("C1", "Some response") # Still success — the user saw the initial ack already @@ -3043,6 +3604,7 @@ class TestSlashEphemeralAck: async def test_send_slash_ephemeral_fallback_on_exception(self, adapter): """_send_slash_ephemeral returns success=True even if aiohttp raises.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/commands/timeout", "ts": time.monotonic(), @@ -3053,7 +3615,9 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch( + "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + ): result = await adapter.send("C1", "Some response") assert result.success is True diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 0aeb8e408..cb5ba6904 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -216,8 +216,11 @@ class TestCmdUpdateBranchFallback: "--no-audit", "--progress=false", ] + # Repo root additionally passes --workspaces=false so npm does not + # recursively install every apps/* workspace (desktop, shared). + repo_flags = [*update_flags, "--workspaces=false"] assert npm_calls[:2] == [ - (update_flags, PROJECT_ROOT), + (repo_flags, PROJECT_ROOT), (update_flags, PROJECT_ROOT / "ui-tui"), ] if len(npm_calls) > 2: diff --git a/tests/hermes_cli/test_dashboard_auth_ws_auth.py b/tests/hermes_cli/test_dashboard_auth_ws_auth.py index a5681408f..ff432e64c 100644 --- a/tests/hermes_cli/test_dashboard_auth_ws_auth.py +++ b/tests/hermes_cli/test_dashboard_auth_ws_auth.py @@ -375,6 +375,50 @@ class TestWsRequestIsAllowedGated: assert web_server._ws_request_is_allowed(ws) is False +class TestWsHostOriginGuardOrigins: + """The WS Origin guard must let the packaged desktop shell connect. + + Electron loads the packaged renderer over ``file://``, so its WebSocket + handshake carries ``Origin: file://`` (or the opaque ``null``). The + DNS-rebinding guard only needs to block cross-site http(s) origins. On a + loopback bind these non-web origins are trusted because the session token + is the real gate. Public/gated binds keep rejecting them. + """ + + def _ws(self, *, origin, host): + ws = _fake_ws(query={}, path="/api/ws") + ws.headers = {"host": host, "origin": origin} + return ws + + def test_loopback_file_origin_allowed(self, loopback_app): + ws = self._ws(origin="file://", host="127.0.0.1:8080") + assert web_server._ws_host_origin_is_allowed(ws) is True + + def test_loopback_null_origin_allowed(self, loopback_app): + ws = self._ws(origin="null", host="127.0.0.1:8080") + assert web_server._ws_host_origin_is_allowed(ws) is True + + def test_loopback_app_scheme_origin_allowed(self, loopback_app): + ws = self._ws(origin="app://hermes", host="127.0.0.1:8080") + assert web_server._ws_host_origin_is_allowed(ws) is True + + def test_loopback_matching_http_origin_allowed(self, loopback_app): + # The dev renderer (vite) loads over http://127.0.0.1:<port>. + ws = self._ws(origin="http://127.0.0.1:5174", host="127.0.0.1:8080") + assert web_server._ws_host_origin_is_allowed(ws) is True + + def test_loopback_cross_site_http_origin_rejected(self, loopback_app): + # DNS-rebinding / cross-site: a real web attacker can only present an + # http(s) origin, and that must still be rejected. + ws = self._ws(origin="http://evil.test", host="127.0.0.1:8080") + assert web_server._ws_host_origin_is_allowed(ws) is False + + def test_gated_file_origin_rejected(self, gated_app): + # A public/gated bind has no legitimate file:// client. + ws = self._ws(origin="file://", host="fly-app.fly.dev") + assert web_server._ws_host_origin_is_allowed(ws) is False + + class TestSidecarUrl: def test_loopback_uses_session_token(self, loopback_app): url = web_server._build_sidecar_url("ch-1") diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 4d78b396b..0988f8fb6 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -579,6 +579,28 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey assert gateway.find_gateway_pids() == [321] +def test_scan_gateway_pids_detects_windows_hermes_exe_case_variants(monkeypatch): + monkeypatch.setattr(gateway, "is_windows", lambda: True) + monkeypatch.setattr(gateway, "_get_ancestor_pids", lambda: set()) + monkeypatch.setattr(gateway.shutil, "which", lambda name: "wmic.exe" if name == "wmic" else None) + + def fake_run(cmd, **kwargs): + if cmd[:4] == ["wmic.exe", "process", "get", "ProcessId,CommandLine"]: + return SimpleNamespace( + returncode=0, + stdout=( + "CommandLine=C:\\Program Files\\Hermes\\Hermes.EXE gateway run --replace\n" + "ProcessId=2468\n\n" + ), + stderr="", + ) + raise AssertionError(f"Unexpected command: {cmd}") + + monkeypatch.setattr(gateway.subprocess, "run", fake_run) + + assert gateway._scan_gateway_pids(set(), all_profiles=True) == [2468] + + # --------------------------------------------------------------------------- # _wait_for_gateway_exit # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py new file mode 100644 index 000000000..963e4563b --- /dev/null +++ b/tests/hermes_cli/test_gui_command.py @@ -0,0 +1,179 @@ +"""Tests for ``hermes gui`` desktop launcher wiring.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_cli import main as cli_main + + +def _ns(**kw): + defaults = dict( + skip_build=False, + source=False, + fake_boot=False, + ignore_existing=False, + hermes_root=None, + cwd=None, + ) + defaults.update(kw) + return argparse.Namespace(**defaults) + + +def _make_desktop_tree(tmp_path: Path) -> Path: + root = tmp_path / "hermes-agent" + desktop_dir = root / "apps" / "desktop" + desktop_dir.mkdir(parents=True) + (desktop_dir / "package.json").write_text("{}", encoding="utf-8") + return root + + +def _make_packaged_executable(root: Path, monkeypatch, platform: str = "darwin") -> Path: + monkeypatch.setattr(cli_main.sys, "platform", platform) + desktop_dir = root / "apps" / "desktop" + if platform == "darwin": + exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes" + elif platform == "win32": + exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" + else: + exe = desktop_dir / "release" / "linux-unpacked" / "hermes" + exe.parent.mkdir(parents=True) + exe.write_text("", encoding="utf-8") + return exe + + +def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) + + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) + launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 0 + mock_install.assert_called_once_with("/usr/bin/npm", root, capture_output=False) + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"] + assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir + assert mock_run.call_args_list[1].args[0] == [str(packaged_exe)] + assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir + + +def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + hermes_root = tmp_path / "custom-hermes" + cwd = tmp_path / "project" + hermes_root.mkdir() + cwd.mkdir() + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) + + ok = subprocess.CompletedProcess([], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \ + pytest.raises(SystemExit): + cli_main.cmd_gui(_ns( + fake_boot=True, + ignore_existing=True, + hermes_root=str(hermes_root), + cwd=str(cwd), + )) + + launch_env = mock_run.call_args_list[1].kwargs["env"] + assert launch_env["HERMES_DESKTOP_BOOT_FAKE"] == "1" + assert launch_env["HERMES_DESKTOP_IGNORE_EXISTING"] == "1" + assert launch_env["HERMES_DESKTOP_HERMES_ROOT"] == str(hermes_root) + assert launch_env["HERMES_DESKTOP_CWD"] == str(cwd) + + +def test_gui_exits_when_npm_missing(tmp_path, monkeypatch, capsys): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + with patch("hermes_cli.main.shutil.which", return_value=None), \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + assert "npm was not found" in capsys.readouterr().out + + +def test_gui_skip_build_requires_existing_packaged_app(tmp_path, monkeypatch, capsys): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.setattr(cli_main.sys, "platform", "darwin") + + with pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 1 + assert "no packaged desktop app" in capsys.readouterr().out + + +def test_gui_skip_build_launches_existing_packaged_app_without_npm(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) + + launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) + + with patch("hermes_cli.main.shutil.which", return_value=None), \ + patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \ + patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 0 + mock_install.assert_not_called() + mock_run.assert_called_once() + assert mock_run.call_args.args[0] == [str(packaged_exe)] + + +def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + build_ok = subprocess.CompletedProcess(["npm", "run", "build"], 0) + launch_ok = subprocess.CompletedProcess(["npm", "exec", "--", "electron", "."], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(source=True)) + + assert exc.value.code == 0 + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "build"] + assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir + assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."] + assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir + + +@pytest.mark.parametrize( + "argv", + [ + ["hermes", "gui"], + ["hermes", "-m", "gpt5", "gui"], + ], +) +def test_gui_is_known_builtin_for_plugin_gating(argv): + with patch.object(sys, "argv", argv): + assert cli_main._plugin_cli_discovery_needed() is False diff --git a/tests/hermes_cli/test_inventory_pricing.py b/tests/hermes_cli/test_inventory_pricing.py new file mode 100644 index 000000000..39c099572 --- /dev/null +++ b/tests/hermes_cli/test_inventory_pricing.py @@ -0,0 +1,98 @@ +"""Tests for inventory._apply_pricing — the pricing/tier enrichment that + +feeds the desktop GUI model picker (and onboarding) so it can show $/Mtok +columns + Free/Pro badges and gate paid models on free Nous accounts, the +same way the `hermes model` CLI picker does. +""" + +import hermes_cli.inventory as inv +import hermes_cli.models as models_mod + + +def _patch_pricing(monkeypatch, *, free_tier, pricing, unavailable=None): + monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda slug, **kw: pricing.get(slug, {})) + monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: free_tier) + monkeypatch.setattr( + models_mod, "partition_nous_models_by_tier", + lambda ids, pr, free_tier: ( + [m for m in ids if m not in (unavailable or [])], + list(unavailable or []), + ), + ) + + +def test_apply_pricing_formats_per_model_prices(monkeypatch): + """Each model gets formatted input/output/cache + a free flag.""" + _patch_pricing( + monkeypatch, + free_tier=False, + pricing={ + "openrouter": { + "a/paid": {"prompt": "0.000003", "completion": "0.000015", "input_cache_read": "0.0000003"}, + "b/free": {"prompt": "0", "completion": "0"}, + } + }, + ) + rows = [{"slug": "openrouter", "models": ["a/paid", "b/free"]}] + inv._apply_pricing(rows) + + pricing = rows[0]["pricing"] + assert pricing["a/paid"] == {"input": "$3.00", "output": "$15.00", "cache": "$0.30", "free": False} + assert pricing["b/free"]["free"] is True + assert pricing["b/free"]["input"] == "free" + + +def test_apply_pricing_nous_free_tier_gates_paid_models(monkeypatch): + """A free-tier Nous account marks paid models unavailable and sets the flag.""" + _patch_pricing( + monkeypatch, + free_tier=True, + pricing={ + "nous": { + "free/model": {"prompt": "0", "completion": "0"}, + "paid/model": {"prompt": "0.000005", "completion": "0.00001"}, + } + }, + unavailable=["paid/model"], + ) + rows = [{"slug": "nous", "models": ["free/model", "paid/model"]}] + inv._apply_pricing(rows) + + assert rows[0]["free_tier"] is True + assert rows[0]["unavailable_models"] == ["paid/model"] + assert rows[0]["pricing"]["free/model"]["free"] is True + + +def test_apply_pricing_nous_paid_tier_no_gating(monkeypatch): + """A paid Nous account gates nothing.""" + _patch_pricing( + monkeypatch, + free_tier=False, + pricing={"nous": {"x/model": {"prompt": "0.000001", "completion": "0.000002"}}}, + ) + rows = [{"slug": "nous", "models": ["x/model"]}] + inv._apply_pricing(rows) + + assert rows[0]["free_tier"] is False + assert rows[0]["unavailable_models"] == [] + + +def test_apply_pricing_skips_providers_without_pricing(monkeypatch): + """A provider with no live pricing simply gets no pricing key.""" + _patch_pricing(monkeypatch, free_tier=False, pricing={}) + rows = [{"slug": "anthropic", "models": ["claude-x"]}] + inv._apply_pricing(rows) + + assert "pricing" not in rows[0] + + +def test_apply_pricing_failure_is_swallowed(monkeypatch): + """A pricing fetch that raises must not break the whole payload.""" + def boom(slug, **kw): + raise RuntimeError("network down") + + monkeypatch.setattr(models_mod, "get_pricing_for_provider", boom) + rows = [{"slug": "openrouter", "models": ["a/b"]}] + inv._apply_pricing(rows) # must not raise + + assert "pricing" not in rows[0] diff --git a/tests/hermes_cli/test_logs.py b/tests/hermes_cli/test_logs.py index 203a37af5..52fa63e3e 100644 --- a/tests/hermes_cli/test_logs.py +++ b/tests/hermes_cli/test_logs.py @@ -250,3 +250,4 @@ class TestLogFiles: assert "agent" in LOG_FILES assert "errors" in LOG_FILES assert "gateway" in LOG_FILES + assert "gui" in LOG_FILES diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index 43ad6e42c..7a1cbd54c 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -368,12 +368,12 @@ class TestIntegrationWithModelsModule: assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] - def test_picker_nous_row_uses_manifest(self, tmp_path, monkeypatch): - """The /model picker must surface the manifest's nous list, not the - in-repo _PROVIDER_MODELS["nous"] snapshot. Regression: before this - fix, list_authenticated_providers() built the curated dict from - _PROVIDER_MODELS only — so newly-added Portal models never reached - the slash-command picker until the next Hermes release. + def test_picker_nous_row_uses_curated_list(self, tmp_path, monkeypatch): + """The /model picker surfaces the curated ``_PROVIDER_MODELS["nous"]`` + list in curated order — matching the ``hermes model`` CLI — not the live + ``/v1/models`` catalog or the manifest. Portal free/paid recommendations + are unioned in when reachable; offline (as here, with the Portal calls + stubbed out) it's exactly the curated list. """ # We deliberately do NOT use the ``isolated_home`` fixture here: # that fixture monkeypatches ``Path.home`` to ``tmp_path``, which @@ -383,6 +383,7 @@ class TestIntegrationWithModelsModule: # ``_hermetic_environment`` HERMES_HOME directly instead. import importlib from hermes_cli import model_catalog + from hermes_cli.models import get_curated_nous_model_ids importlib.reload(model_catalog) try: from hermes_cli.model_switch import list_picker_providers @@ -397,9 +398,21 @@ class TestIntegrationWithModelsModule: ) ) + # Stub the Portal recommendation union so the row is deterministic + # (the curated list alone) and never touches the network. ``expected`` + # is computed from the same source the picker uses internally + # (``curated["nous"] = get_curated_nous_model_ids()``), so the test + # stays an invariant — it can't rot as the curated/manifest list grows. with patch.object( model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ), patch("hermes_cli.models.check_nous_free_tier", return_value=False), patch( + "hermes_cli.models.union_with_portal_free_recommendations", + side_effect=lambda ids, *a, **k: (ids, {}), + ), patch( + "hermes_cli.models.union_with_portal_paid_recommendations", + side_effect=lambda ids, *a, **k: (ids, {}), ): + expected = get_curated_nous_model_ids() picker = list_picker_providers( current_provider="nous", max_models=99 ) @@ -408,10 +421,7 @@ class TestIntegrationWithModelsModule: nous_row = next((r for r in picker if r["slug"] == "nous"), None) assert nous_row is not None, "nous row must appear when authed" - assert nous_row["models"] == [ - "anthropic/claude-opus-4.7", - "moonshotai/kimi-k2.6", - ] + assert nous_row["models"] == expected # ----------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 5f8abcc87..8af16b8e1 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -422,117 +422,6 @@ class TestCmdList: cmd_list() -# ── _discover_all_plugins tests ─────────────────────────────────────────────── - - -class TestDiscoverAllPlugins: - """Exercise the recursive scan that powers ``hermes plugins list``. - - Mirrors the layouts the runtime loader handles - (:meth:`PluginManager._scan_directory_level`): flat plugins at the root, - category-namespaced plugins one level deeper, and user-overrides-bundled - on key collision. - """ - - @staticmethod - def _write_plugin(root: Path, segments: list, manifest_name: str = None) -> None: - plugin_dir = root - for seg in segments: - plugin_dir = plugin_dir / seg - plugin_dir.mkdir(parents=True, exist_ok=True) - manifest = { - "name": manifest_name or segments[-1], - "version": "0.1.0", - "description": f"Test plugin {'/'.join(segments)}", - } - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) - - def _entries_by_key(self, tmp_path, monkeypatch) -> dict: - from hermes_cli import plugins_cmd - bundled = tmp_path / "bundled" - user = tmp_path / "user" - bundled.mkdir() - user.mkdir() - monkeypatch.setattr( - "hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled - ) - monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user) - return bundled, user, lambda: { - e[0]: e for e in plugins_cmd._discover_all_plugins() - } - - def test_flat_plugin_uses_manifest_name_as_key(self, tmp_path, monkeypatch): - bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) - self._write_plugin(bundled, ["disk-cleanup"]) - - entries = discover() - assert "disk-cleanup" in entries - assert entries["disk-cleanup"][3] == "bundled" - - def test_category_namespaced_plugin_uses_path_derived_key( - self, tmp_path, monkeypatch - ): - """Regression test for the original bug — ``observability/langfuse`` - and ``image_gen/openai`` must surface under their path-derived key, - not vanish because the category directory has no ``plugin.yaml``.""" - bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) - # langfuse's real manifest declares ``name: langfuse`` (bare), but it - # lives under ``observability/`` — the key must reflect the path. - self._write_plugin( - bundled, ["observability", "langfuse"], manifest_name="langfuse" - ) - self._write_plugin(bundled, ["image_gen", "openai"]) - - entries = discover() - assert "observability/langfuse" in entries - assert "image_gen/openai" in entries - # Bare manifest name must NOT leak through as a top-level key. - assert "langfuse" not in entries - assert "openai" not in entries - - def test_user_overrides_bundled_on_key_collision(self, tmp_path, monkeypatch): - bundled, user, discover = self._entries_by_key(tmp_path, monkeypatch) - self._write_plugin(bundled, ["observability", "langfuse"]) - self._write_plugin(user, ["observability", "langfuse"]) - - entries = discover() - assert entries["observability/langfuse"][3] == "user" - - def test_depth_cap_skips_third_level(self, tmp_path, monkeypatch): - """Anything deeper than ``<root>/<category>/<plugin>/`` is ignored, - matching the loader's depth cap.""" - bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) - # plugins/a/b/c/plugin.yaml — too deep, must NOT be discovered. - self._write_plugin(bundled, ["a", "b", "c"]) - - entries = discover() - assert not any(k.startswith("a/") for k in entries), entries - - def test_bundled_memory_and_context_engine_skipped(self, tmp_path, monkeypatch): - """``plugins/memory/`` and ``plugins/context_engine/`` use their own - loaders; bundled entries inside them must not appear in the general - list (matches the pre-refactor skip set).""" - bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) - self._write_plugin(bundled, ["memory", "honcho"]) - self._write_plugin(bundled, ["context_engine", "compressor"]) - self._write_plugin(bundled, ["observability", "langfuse"]) - - entries = discover() - assert "memory/honcho" not in entries - assert "context_engine/compressor" not in entries - assert "observability/langfuse" in entries - - def test_user_memory_subdir_is_still_scanned(self, tmp_path, monkeypatch): - """The memory/context_engine skip only applies to *bundled* — a user - plugin at ``~/.hermes/plugins/memory/<x>/`` should still be discovered - so the user can see what they installed.""" - bundled, user, discover = self._entries_by_key(tmp_path, monkeypatch) - self._write_plugin(user, ["memory", "my-custom-store"]) - - entries = discover() - assert "memory/my-custom-store" in entries - - # ── _copy_example_files tests ───────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index fc2906d73..07373e81e 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -1400,3 +1400,57 @@ def test_configure_non_managed_provider_skips_portal_gate(monkeypatch): assert called["gate"] is False assert config["web"]["backend"] == "tavily" assert config["web"]["use_gateway"] is False + + +def test_apply_provider_selection_web_sets_backend(): + """Selecting a web provider persists the backend without prompting for keys.""" + from hermes_cli.tools_config import apply_provider_selection + + config = {} + apply_provider_selection("web", "Firecrawl Self-Hosted", config) + + assert config["web"]["backend"] == "firecrawl" + assert config["web"]["use_gateway"] is False + + +def test_apply_provider_selection_tts_sets_provider(): + """Selecting a TTS provider persists tts.provider.""" + from hermes_cli.tools_config import apply_provider_selection + + config = {} + apply_provider_selection("tts", "Microsoft Edge TTS", config) + + assert config["tts"]["provider"] == "edge" + assert config["tts"]["use_gateway"] is False + + +def test_apply_provider_selection_unknown_provider_raises_keyerror(): + from hermes_cli.tools_config import apply_provider_selection + + with pytest.raises(KeyError): + apply_provider_selection("web", "No Such Provider", {}) + + +def test_apply_provider_selection_unknown_toolset_raises_keyerror(): + from hermes_cli.tools_config import apply_provider_selection + + with pytest.raises(KeyError): + apply_provider_selection("not_a_toolset", "whatever", {}) + + +def test_apply_provider_selection_does_not_prompt_or_post_setup(monkeypatch): + """The non-interactive selection must not invoke prompts or post-setup hooks.""" + from hermes_cli import tools_config + + monkeypatch.setattr( + tools_config, "_run_post_setup", + lambda *a, **k: pytest.fail("post-setup must not run on provider selection"), + ) + monkeypatch.setattr( + tools_config, "_prompt", + lambda *a, **k: pytest.fail("env prompting must not run on provider selection"), + ) + config = {} + tools_config.apply_provider_selection("tts", "Microsoft Edge TTS", config) + assert config["tts"]["provider"] == "edge" + diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index cdc577d09..86d9e99c7 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2,6 +2,7 @@ import os import json +from pathlib import Path from unittest.mock import patch, MagicMock import pytest @@ -88,6 +89,35 @@ class TestRedactKey: assert "not set" in result.lower() or result == "***" or "\x1b" in result +class TestSessionTokenInjection: + """The desktop shell mints HERMES_DASHBOARD_SESSION_TOKEN and signs its + /api + /api/ws calls with it. The backend must adopt that token, else every + desktop request 401s ("gateway is offline"). A main-merge once silently + dropped this read — this guards the contract, not a literal value. + """ + + def test_honors_injected_token(self, monkeypatch): + import importlib + import hermes_cli.web_server as ws + + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token") + try: + importlib.reload(ws) + assert ws._SESSION_TOKEN == "desktop-seeded-token" + finally: + monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False) + importlib.reload(ws) + + def test_falls_back_to_random_token(self, monkeypatch): + import importlib + import hermes_cli.web_server as ws + + monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False) + importlib.reload(ws) + + assert ws._SESSION_TOKEN and len(ws._SESSION_TOKEN) >= 32 + + # --------------------------------------------------------------------------- # web_server tests (FastAPI endpoints) # --------------------------------------------------------------------------- @@ -121,6 +151,158 @@ class TestWebServerEndpoints: assert "hermes_home" in data assert "active_sessions" in data + def test_get_sessions_uses_only_persisted_cwd(self, monkeypatch): + """Session rows without persisted cwd must not inherit TERMINAL_CWD. + + /api/sessions should reflect per-session DB state, not process/global + cwd settings, so workspace grouping stays stable and deterministic. + """ + from hermes_state import SessionDB + + monkeypatch.setenv("TERMINAL_CWD", "/tmp/global-default") + + db = SessionDB() + try: + db.create_session(session_id="session-no-cwd", source="cli") + finally: + db.close() + + resp = self.client.get("/api/sessions?limit=20&offset=0") + assert resp.status_code == 200 + + rows = resp.json()["sessions"] + row = next(s for s in rows if s["id"] == "session-no-cwd") + assert row["cwd"] is None + + def test_get_sessions_forwards_min_messages(self, monkeypatch): + """The ?min_messages= filter must reach SessionDB. + + The desktop session picker calls /api/sessions?...&min_messages=N to + hide empty sessions. The param was silently dropped from the handler + in a merge once (SessionDB still supported it); guard the wiring. + """ + captured = {} + + class _FakeDB: + def __init__(self, *args, **kwargs): + pass + + def list_sessions_rich(self, limit, offset, min_message_count=0): + captured["list"] = min_message_count + return [] + + def session_count(self, min_message_count=0): + captured["count"] = min_message_count + return 0 + + def close(self): + pass + + monkeypatch.setattr("hermes_state.SessionDB", _FakeDB) + + resp = self.client.get("/api/sessions?limit=5&offset=0&min_messages=3") + assert resp.status_code == 200 + assert captured["list"] == 3 + assert captured["count"] == 3 + + def test_audio_transcription_endpoint(self, monkeypatch): + import tools.transcription_tools as transcription_tools + + captured = {} + + def fake_transcribe_audio(path): + captured["path"] = path + return { + "success": True, + "transcript": "hello from voice mode", + "provider": "test", + } + + monkeypatch.setattr(transcription_tools, "transcribe_audio", fake_transcribe_audio) + + resp = self.client.post( + "/api/audio/transcribe", + json={ + "data_url": "data:audio/webm;base64,aGVsbG8=", + "mime_type": "audio/webm", + }, + ) + + assert resp.status_code == 200 + assert resp.json() == { + "ok": True, + "transcript": "hello from voice mode", + "provider": "test", + } + assert captured["path"].endswith(".webm") + assert not Path(captured["path"]).exists() + + def test_audio_transcription_rejects_invalid_base64(self): + resp = self.client.post( + "/api/audio/transcribe", + json={ + "data_url": "data:audio/webm;base64,not base64", + "mime_type": "audio/webm", + }, + ) + + assert resp.status_code == 400 + assert "base64" in resp.json()["detail"] + + def test_desktop_audio_routes_registered(self): + """All three desktop voice endpoints must exist. + + The renderer (apps/desktop) calls /api/audio/transcribe, /speak, and + /elevenlabs/voices. /speak + /voices were silently dropped in a merge + once; this guards the contract so a future merge can't lose them + without failing CI. + """ + from hermes_cli.web_server import app + + paths = {getattr(r, "path", None) for r in app.routes} + assert "/api/audio/transcribe" in paths + assert "/api/audio/speak" in paths + assert "/api/audio/elevenlabs/voices" in paths + + def test_elevenlabs_voices_unavailable_without_key(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "load_env", lambda: {}) + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + + resp = self.client.get("/api/audio/elevenlabs/voices") + assert resp.status_code == 200 + assert resp.json() == {"available": False, "voices": []} + + def test_speak_text_returns_base64_data_url(self, monkeypatch, tmp_path): + import tools.tts_tool as tts_tool + + audio_file = tmp_path / "speech.mp3" + audio_file.write_bytes(b"ID3fake-audio-bytes") + + def fake_tts(text): + return json.dumps({ + "success": True, + "file_path": str(audio_file), + "provider": "test", + }) + + monkeypatch.setattr(tts_tool, "text_to_speech_tool", fake_tts) + + resp = self.client.post("/api/audio/speak", json={"text": "hello there"}) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["mime_type"] == "audio/mpeg" + assert body["data_url"].startswith("data:audio/mpeg;base64,") + assert body["provider"] == "test" + # The handler streams the bytes back and removes the temp file. + assert not audio_file.exists() + + def test_speak_text_requires_nonempty_text(self): + resp = self.client.post("/api/audio/speak", json={"text": " "}) + assert resp.status_code == 400 + def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): import gateway.config as gateway_config import hermes_cli.web_server as web_server @@ -297,6 +479,82 @@ class TestWebServerEndpoints: assert resp.status_code == 200 + def test_get_messaging_platforms(self): + resp = self.client.get("/api/messaging/platforms") + + assert resp.status_code == 200 + platforms = resp.json()["platforms"] + telegram = next(platform for platform in platforms if platform["id"] == "telegram") + assert telegram["name"] == "Telegram" + assert telegram["enabled"] is False + assert any(field["key"] == "TELEGRAM_BOT_TOKEN" and field["required"] for field in telegram["env_vars"]) + + def test_messaging_catalog_covers_gateway_platforms(self): + """Catalog is derived from the Platform enum, so every built-in shows up.""" + from gateway.config import Platform + + resp = self.client.get("/api/messaging/platforms") + platforms = {entry["id"] for entry in resp.json()["platforms"]} + + for member in Platform.__members__.values(): + if member.value == "local": + continue + assert member.value in platforms, f"Missing gateway platform {member.value} from /api/messaging/platforms" + + def test_messaging_catalog_includes_plugin_platforms(self, monkeypatch): + """Plugin-registered adapters appear in the catalog without per-platform code.""" + from gateway.platform_registry import PlatformEntry, platform_registry + + entry = PlatformEntry( + name="ircfake", + label="IRC (test)", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + required_env=["IRC_SERVER"], + install_hint="Connect to IRC.", + source="plugin", + ) + platform_registry.register(entry) + try: + resp = self.client.get("/api/messaging/platforms") + ids = {row["id"]: row for row in resp.json()["platforms"]} + assert "ircfake" in ids + assert ids["ircfake"]["name"] == "IRC (test)" + assert any(field["key"] == "IRC_SERVER" and field["required"] for field in ids["ircfake"]["env_vars"]) + finally: + platform_registry.unregister("ircfake") + + def test_update_messaging_platform_saves_env_and_enablement(self): + from hermes_cli.config import load_config, load_env + + resp = self.client.put( + "/api/messaging/platforms/telegram", + json={ + "enabled": False, + "env": {"TELEGRAM_BOT_TOKEN": "1234567890abcdef"}, + }, + ) + + assert resp.status_code == 200 + assert load_env()["TELEGRAM_BOT_TOKEN"] == "1234567890abcdef" + assert load_config()["platforms"]["telegram"]["enabled"] is False + + status = self.client.get("/api/messaging/platforms").json()["platforms"] + telegram = next(platform for platform in status if platform["id"] == "telegram") + assert telegram["enabled"] is False + + def test_messaging_platform_test_reports_missing_required_setup(self): + resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True}) + assert resp.status_code == 200 + + resp = self.client.post("/api/messaging/platforms/discord/test") + + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is False + assert data["state"] == "not_configured" + assert "DISCORD_BOT_TOKEN" in data["message"] + def test_session_token_endpoint_removed(self): """GET /api/auth/session-token should no longer exist (token injected via HTML).""" resp = self.client.get("/api/auth/session-token") @@ -347,6 +605,133 @@ class TestWebServerEndpoints: if resp.status_code == 200: assert "FastAPI" not in resp.text # Should not serve the actual source + def test_set_model_main_nous_applies_gateway_defaults(self, monkeypatch): + """Switching the main provider to Nous calls apply_nous_managed_defaults + (mirroring the CLI's post-model-selection Tool Gateway routing) and + surfaces the routed tools in the response.""" + import hermes_cli.nous_subscription as ns + + called = {} + + def fake_apply(config, *, enabled_toolsets=None, force_fresh=False): + called["enabled"] = set(enabled_toolsets or ()) + called["force_fresh"] = force_fresh + # Simulate routing the unconfigured web tool through the gateway. + web = config.setdefault("web", {}) + web["backend"] = "firecrawl" + return {"web"} + + monkeypatch.setattr(ns, "apply_nous_managed_defaults", fake_apply) + + resp = self.client.post( + "/api/model/set", + json={"scope": "main", "provider": "nous", "model": "hermes-4"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["provider"] == "nous" + assert data["gateway_tools"] == ["web"] + assert called["force_fresh"] is True + + def test_set_model_main_non_nous_skips_gateway_defaults(self, monkeypatch): + """Non-Nous providers must NOT trigger Tool Gateway auto-routing.""" + import hermes_cli.nous_subscription as ns + + def boom(*args, **kwargs): # pragma: no cover - must not be called + raise AssertionError("apply_nous_managed_defaults called for non-nous provider") + + monkeypatch.setattr(ns, "apply_nous_managed_defaults", boom) + + resp = self.client.post( + "/api/model/set", + json={"scope": "main", "provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data.get("gateway_tools", []) == [] + + def test_set_model_main_gateway_failure_does_not_block_save(self, monkeypatch): + """A Portal/gateway hiccup must never prevent saving the model.""" + import hermes_cli.nous_subscription as ns + + def boom(*args, **kwargs): + raise RuntimeError("portal unreachable") + + monkeypatch.setattr(ns, "apply_nous_managed_defaults", boom) + + resp = self.client.post( + "/api/model/set", + json={"scope": "main", "provider": "nous", "model": "hermes-4"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data.get("gateway_tools", []) == [] + + def test_recommended_default_nous_honors_free_tier(self, monkeypatch): + """For a free-tier Nous user, the recommended default must be a free + model (mirroring `hermes model`), not the first curated paid entry.""" + import hermes_cli.models as models_mod + + monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", lambda: ["paid/expensive", "free/cheap"]) + monkeypatch.setattr( + models_mod, "get_pricing_for_provider", + lambda provider: {"paid/expensive": {"input": "1"}, "free/cheap": {"input": "0"}}, + ) + monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: True) + monkeypatch.setattr( + models_mod, "union_with_portal_free_recommendations", + lambda ids, pricing, url: (ids, pricing), + ) + # Free partition keeps only the free model selectable. + monkeypatch.setattr( + models_mod, "partition_nous_models_by_tier", + lambda ids, pricing, free_tier: (["free/cheap"], ["paid/expensive"]), + ) + + resp = self.client.get("/api/model/recommended-default?provider=nous") + assert resp.status_code == 200 + data = resp.json() + assert data["provider"] == "nous" + assert data["model"] == "free/cheap" + assert data["free_tier"] is True + + def test_recommended_default_nous_paid_uses_curated_default(self, monkeypatch): + """A paid Nous user gets the first curated/paid-augmented model.""" + import hermes_cli.models as models_mod + + monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", lambda: ["top/model", "other/model"]) + monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda provider: {}) + monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: False) + monkeypatch.setattr( + models_mod, "union_with_portal_paid_recommendations", + lambda ids, pricing, url: (ids, pricing), + ) + + resp = self.client.get("/api/model/recommended-default?provider=nous") + assert resp.status_code == 200 + data = resp.json() + assert data["provider"] == "nous" + assert data["model"] == "top/model" + assert data["free_tier"] is False + + def test_recommended_default_handles_failure_gracefully(self, monkeypatch): + """Endpoint never 500s — returns empty model on internal error.""" + import hermes_cli.models as models_mod + + def boom(): + raise RuntimeError("portal down") + + monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", boom) + + resp = self.client.get("/api/model/recommended-default?provider=nous") + assert resp.status_code == 200 + data = resp.json() + assert data["model"] == "" + assert data["free_tier"] is None + # --------------------------------------------------------------------------- # _build_schema_from_config tests @@ -927,6 +1312,93 @@ class TestNewEndpoints: }, ] + def test_toggle_toolset_enable_disable(self): + """PUT /api/tools/toolsets/{name} round-trips through config and the list view.""" + # Enable a toolset that is off-by-default so the state change is observable. + resp = self.client.put("/api/tools/toolsets/x_search", json={"enabled": True}) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["name"] == "x_search" + assert body["enabled"] is True + + listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()} + assert listing["x_search"]["enabled"] is True + + # Disable it again. + resp = self.client.put("/api/tools/toolsets/x_search", json={"enabled": False}) + assert resp.status_code == 200 + assert resp.json()["enabled"] is False + + listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()} + assert listing["x_search"]["enabled"] is False + + def test_toggle_toolset_unknown_returns_400(self): + resp = self.client.put( + "/api/tools/toolsets/not_a_real_toolset", json={"enabled": True} + ) + assert resp.status_code == 400 + + def test_get_toolset_config_returns_provider_matrix(self): + """GET .../config returns provider rows with structured env_vars.""" + resp = self.client.get("/api/tools/toolsets/tts/config") + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "tts" + assert data["has_category"] is True + assert isinstance(data["providers"], list) + assert data["providers"], "tts always has at least the built-in providers" + for prov in data["providers"]: + assert "name" in prov + assert "env_vars" in prov + assert isinstance(prov["env_vars"], list) + for ev in prov["env_vars"]: + assert "key" in ev + assert "is_set" in ev + + def test_get_toolset_config_no_category_toolset(self): + """A toolset without a TOOL_CATEGORIES entry returns has_category False.""" + resp = self.client.get("/api/tools/toolsets/todo/config") + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "todo" + assert data["has_category"] is False + assert data["providers"] == [] + + def test_get_toolset_config_unknown_returns_400(self): + resp = self.client.get("/api/tools/toolsets/not_a_real_toolset/config") + assert resp.status_code == 400 + + def test_select_toolset_provider_persists_backend(self): + """PUT .../provider writes the backend selection to config.""" + resp = self.client.put( + "/api/tools/toolsets/web/provider", + json={"provider": "Firecrawl Self-Hosted"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["name"] == "web" + assert body["provider"] == "Firecrawl Self-Hosted" + + from hermes_cli.config import load_config + cfg = load_config() + assert cfg["web"]["backend"] == "firecrawl" + + def test_select_toolset_provider_unknown_provider_returns_400(self): + resp = self.client.put( + "/api/tools/toolsets/web/provider", + json={"provider": "No Such Provider"}, + ) + assert resp.status_code == 400 + + def test_select_toolset_provider_unknown_toolset_returns_400(self): + resp = self.client.put( + "/api/tools/toolsets/not_a_real_toolset/provider", + json={"provider": "whatever"}, + ) + assert resp.status_code == 400 + def test_config_raw_get(self): resp = self.client.get("/api/config/raw") assert resp.status_code == 200 @@ -2373,6 +2845,26 @@ class TestPtyWebSocket: assert exc.value.code == 4400 +def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch): + import hermes_cli.main as cli_main + import hermes_cli.web_server as ws + + monkeypatch.setattr( + cli_main, + "_make_tui_argv", + lambda *_args, **_kwargs: (["node", "fake-tui.js"], Path("/tmp")), + ) + monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False) + monkeypatch.setattr(ws.app.state, "bound_port", 9119, raising=False) + + _argv, _cwd, env = ws._resolve_chat_argv() + + assert env is not None + gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "") + assert gateway_url.startswith("ws://127.0.0.1:9119/api/ws?") + assert "token=" in gateway_url + + class TestDashboardPluginStaticAssetAllowlist: """``/dashboard-plugins/<name>/<path>`` is unauthenticated by design — the SPA loads plugin JS via ``<script src>`` and CSS via @@ -2446,3 +2938,81 @@ class TestDashboardPluginStaticAssetAllowlist: # — never 200. assert resp.status_code in (403, 404) + +def _fake_httpx_client(*, status: int | None = None, raise_exc: bool = False): + """Build a drop-in for httpx.Client whose .get() returns a canned status + (or raises a transport error). Patched in for the credential-validate probe + so tests never touch the network.""" + class _Resp: + def __init__(self, code): + self.status_code = code + + @property + def is_success(self): + return 200 <= self.status_code < 300 + + class _Client: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, *a, **k): + if raise_exc: + raise RuntimeError("connection refused") + return _Resp(status) + + return _Client + + +class TestValidateProviderCredential: + """Live-probe credential validation (/api/providers/validate).""" + + @pytest.fixture(autouse=True) + def _setup_test_client(self, monkeypatch, _isolate_hermes_home): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + self.client = TestClient(app) + self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + def _post(self, key, value): + return self.client.post("/api/providers/validate", json={"key": key, "value": value}) + + def test_rejected_key_blocks(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(status=401)) + data = self._post("OPENROUTER_API_KEY", "sk-bogus").json() + assert data["ok"] is False and data["reachable"] is True + + def test_valid_key_passes(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(status=200)) + data = self._post("OPENAI_API_KEY", "sk-real").json() + assert data["ok"] is True and data["reachable"] is True + + def test_rate_limited_counts_as_valid(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(status=429)) + data = self._post("XAI_API_KEY", "xai-real").json() + assert data["ok"] is True + + def test_network_error_is_unreachable_not_blocking(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(raise_exc=True)) + data = self._post("OPENROUTER_API_KEY", "sk-real").json() + assert data["ok"] is False and data["reachable"] is False + + def test_unknown_provider_is_not_validated(self): + # No probe for this key → don't block (ok True, reachable False). + data = self._post("SOME_OTHER_API_KEY", "whatever-value").json() + assert data["ok"] is True and data["reachable"] is False + + def test_empty_value_rejected(self): + data = self._post("OPENAI_API_KEY", " ").json() + assert data["ok"] is False + diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index bf887955a..c8e28a024 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -1,7 +1,7 @@ """Tests for _web_ui_build_needed — staleness check for the web UI dist. -Critical invariant: the Vite build outputs to hermes_cli/web_dist/ -(vite.config.ts: outDir: "../hermes_cli/web_dist"), NOT web/dist/. +Critical invariant: the dashboard Vite build outputs to hermes_cli/web_dist/ +(vite.config.ts: outDir: "../../hermes_cli/web_dist"), NOT web/dist/. The sentinel must be checked in the correct output directory or the freshness check is a no-op and the OOM rebuild always runs. """ @@ -26,7 +26,7 @@ def _touch(path: Path, offset: float = 0.0) -> None: def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]: """Return (web_dir, dist_dir) matching real repo layout.""" web_dir = tmp_path / "web" - web_dir.mkdir() + web_dir.mkdir(parents=True) (web_dir / "package.json").touch() dist_dir = tmp_path / "hermes_cli" / "web_dist" return web_dir, dist_dir diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index a82eb7e62..8db6c2626 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -172,22 +172,19 @@ def test_codex_stream_truncated_no_terminal_event_raises(): # --------------------------------------------------------------------------- -# Fix B: surface xAI's entitlement body verbatim (no editorializing) -# -# The original PR #26644 appended a hint that led with "X Premium+ does NOT -# include xAI API access — only standalone SuperGrok subscribers can use this -# provider." xAI announced on 2026-05-16 that X Premium subs now work in -# Hermes (https://x.ai/news/grok-hermes), making that hint actively wrong: -# a Premium+ user hitting a real entitlement issue (no Grok sub, wrong tier, -# exhausted quota) would be misdirected to switch subscriptions when their -# Premium sub is in fact valid. We now surface xAI's own body text verbatim -# (which already says "Manage subscriptions at https://grok.com/?_s=usage") -# and leave the diagnosis to xAI's wording. +# Fix B: friendly entitlement message # --------------------------------------------------------------------------- -def test_summarize_api_error_surfaces_xai_entitlement_body_verbatim(): - """xAI's OAuth 403 body must surface as-is, with no Hermes-side hint.""" +def test_summarize_api_error_decorates_xai_entitlement_403(): + """xAI's OAuth 403 must surface the X Premium+ gotcha + neutral causes. + + Wording deliberately leads with the X Premium+ gotcha because that's + the #1 confusing case: people see Grok in their X app, assume it + works here too, and hit this 403 with no idea API access is a + separate SKU. Other causes (no subscription, wrong tier, exhausted + quota) follow. + """ from run_agent import AIAgent error = RuntimeError( @@ -197,15 +194,45 @@ def test_summarize_api_error_surfaces_xai_entitlement_body_verbatim(): "subscriptions at https://grok.com'}" ) summary = AIAgent._summarize_api_error(error) - # xAI's own body text must reach the user — they need it to diagnose. + # The original xAI text must survive — it's still useful diagnostic info. assert "do not have an active Grok subscription" in summary - # No stale claim that X Premium is incompatible with Hermes. - assert "X Premium+ does NOT include" not in summary - assert "standalone SuperGrok subscribers" not in summary + # The hint MUST lead with the X Premium+ gotcha (most likely cause + # for users who think they're subscribed). + assert "X Premium+ does NOT include" in summary + assert "standalone SuperGrok subscribers" in summary + # Other causes still listed. + assert "no Grok subscription" in summary + assert "tier doesn't include this model" in summary + assert "quota is exhausted" in summary + # The hint must point at the usage page where the user can verify. + assert "https://grok.com/?_s=usage" in summary + # Switching providers is still a valid escape hatch. + assert "/model" in summary -def test_summarize_api_error_xai_body_message_unwrapped(): - """SDK-style error with structured body surfaces the message cleanly.""" +def test_summarize_api_error_does_not_accuse_subscribers(): + """Hint must not confidently say the user has no subscription. + + Don Piedro reported his subscription is active. The hint must not + contradict him — leading with the X Premium+ gotcha gives subscribers + a plausible reason ("oh, I'm on Premium+ not pure SuperGrok") instead + of accusing them of lying about having a subscription. + """ + from run_agent import AIAgent + + error = RuntimeError( + "HTTP 403: do not have an active Grok subscription" + ) + summary = AIAgent._summarize_api_error(error) + # MUST NOT contain language that flatly assumes the user is unsubscribed. + assert "lacks SuperGrok" not in summary + assert "you are not subscribed" not in summary.lower() + # MUST lead with the most-likely-but-non-accusatory cause. + assert "X Premium+ does NOT include" in summary + + +def test_summarize_api_error_decorates_xai_body_message(): + """SDK-style error with structured body must also get the hint.""" from run_agent import AIAgent class _XaiErr(Exception): @@ -222,9 +249,19 @@ def test_summarize_api_error_xai_body_message_unwrapped(): summary = AIAgent._summarize_api_error(_XaiErr("403")) assert "HTTP 403" in summary - assert "do not have an active Grok subscription" in summary - # No editorializing on top of xAI's own wording. - assert "X Premium+ does NOT include" not in summary + assert "X Premium+ does NOT include" in summary + + +def test_summarize_api_error_idempotent_for_entitlement_hint(): + """Decorating twice must not double up the hint.""" + from run_agent import AIAgent + + raw = "HTTP 403: do not have an active Grok subscription" + once = AIAgent._decorate_xai_entitlement_error(raw) + twice = AIAgent._decorate_xai_entitlement_error(once) + assert once == twice + # Sanity: the hint did fire on the first pass. + assert "X Premium+ does NOT include" in once def test_summarize_api_error_passes_through_unrelated_errors(): diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index 07fdecce8..7aee14187 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -121,26 +121,6 @@ class TestRestorePrimaryRuntime: assert agent._fallback_activated is False assert agent._restore_primary_runtime() is False - def test_resets_index_when_fallback_not_activated(self): - """Regression for #20465: failed activation leaves _fallback_index advanced - with _fallback_activated=False; the next turn's restore must reset the index.""" - fbs = [{"provider": "custom", "model": "gpt-oss:20b", - "base_url": "http://host.docker.internal:11434/v1", "api_key": "ollama"}] - agent = _make_agent(fallback_model=fbs) - - # resolve_provider_client returns None → _try_activate_fallback returns False - # but _fallback_index has already been incremented to 1 - with patch("agent.auxiliary_client.resolve_provider_client", return_value=(None, None)): - assert agent._try_activate_fallback() is False - - assert agent._fallback_activated is False - assert agent._fallback_index == 1 # advanced past the only entry - - # _restore_primary_runtime must reset the index so the next turn can retry - result = agent._restore_primary_runtime() - assert result is False # still no-op (primary was never left) - assert agent._fallback_index == 0 # chain available again - def test_restores_model_and_provider(self): agent = _make_agent( fallback_model={"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}, diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 2bef65887..a495b7183 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4315,37 +4315,6 @@ class TestCredentialPoolRecovery: assert retry_same is False agent._swap_credential.assert_called_once_with(next_entry) - def test_recover_with_pool_rotates_usage_limit_429_immediately(self, agent): - next_entry = SimpleNamespace(label="secondary") - captured = {} - - class _Pool: - def current(self): - return SimpleNamespace(label="primary") - - def mark_exhausted_and_rotate(self, *, status_code, error_context=None): - captured["status_code"] = status_code - captured["error_context"] = error_context - return next_entry - - agent._credential_pool = _Pool() - agent._swap_credential = MagicMock() - - recovered, retry_same = agent._recover_with_credential_pool( - status_code=429, - has_retried_429=False, - error_context={ - "reason": "usage_limit_reached", - "message": "The usage limit has been reached", - }, - ) - - assert recovered is True - assert retry_same is False - assert captured["status_code"] == 429 - assert captured["error_context"]["reason"] == "usage_limit_reached" - agent._swap_credential.assert_called_once_with(next_entry) - def test_recover_with_pool_refreshes_on_401(self, agent): """401 with successful refresh should swap to refreshed credential.""" diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index 79c241adf..5af349fa8 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -1573,145 +1573,3 @@ class TestCopilotACPStreamingDecision: _use_streaming = False assert _use_streaming is True - - -class TestCodexFallbackErrorEvent: - """Provider ``error`` SSE frames must surface the real message, - not the generic "did not emit a terminal response" RuntimeError. - - xAI emits ``type=error`` as the FIRST frame on the Responses stream - when an OAuth account is unsubscribed/exhausted (May 2026 - SuperGrok rollout). The SDK helper raises - ``RuntimeError("Expected to have received response.created before - error")`` which the caller catches and routes to - ``_run_codex_create_stream_fallback``. The fallback then opens a - NEW stream that emits the same ``type=error`` frame; before this - fix it ignored the event entirely and raised a useless RuntimeError. - """ - - def _make_agent(self): - from run_agent import AIAgent - agent = AIAgent( - api_key="test-key", - base_url="https://api.x.ai/v1", - provider="xai-oauth", - model="grok-4.3", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - agent.api_mode = "codex_responses" - agent._touch_activity = lambda desc: None - return agent - - def test_fallback_raises_synthesized_error_with_xai_subscription_message(self): - from run_agent import _StreamErrorEvent - - agent = self._make_agent() - - error_event = SimpleNamespace( - type="error", - message=( - "Forbidden: The caller does not have permission to execute the specified operation. " - "'You have either run out of available resources or do not have an active Grok subscription.'" - ), - code="permission_denied", - param=None, - sequence_number=1, - ) - - class _FakeStream: - def __iter__(self_inner): - return iter([error_event]) - def close(self_inner): - return None - - mock_client = MagicMock() - mock_client.responses.create.return_value = _FakeStream() - - with pytest.raises(_StreamErrorEvent) as excinfo: - agent._run_codex_create_stream_fallback( - {"model": "grok-4.3", "instructions": "hi", "input": []}, - client=mock_client, - ) - - exc = excinfo.value - assert "active Grok subscription" in str(exc) - assert exc.code == "permission_denied" - assert isinstance(exc.body, dict) - assert exc.body["error"]["message"] == error_event.message - # _extract_api_error_context reads .body["error"]["message"] — make sure - # the entitlement detector will find the subscription phrase there. - assert "active Grok subscription" in exc.body["error"]["message"] - - def test_fallback_dict_event_payload_is_also_handled(self): - """Some relays deliver events as plain dicts instead of model - objects; the dict branch in the loop must surface them too.""" - from run_agent import _StreamErrorEvent - - agent = self._make_agent() - - error_event = { - "type": "error", - "message": "rate_limited", - "code": "rate_limit_exceeded", - } - - class _FakeStream: - def __iter__(self_inner): - return iter([error_event]) - def close(self_inner): - return None - - mock_client = MagicMock() - mock_client.responses.create.return_value = _FakeStream() - - with pytest.raises(_StreamErrorEvent) as excinfo: - agent._run_codex_create_stream_fallback( - {"model": "grok-4.3", "instructions": "hi", "input": []}, - client=mock_client, - ) - - assert "rate_limited" in str(excinfo.value) - assert excinfo.value.code == "rate_limit_exceeded" - - def test_fallback_surfaces_message_useful_to_summarizer(self): - """The synthesized exception must be readable by - ``_summarize_api_error`` so the user-facing log line shows the - real provider message instead of a generic class name.""" - from run_agent import AIAgent, _StreamErrorEvent - - agent = self._make_agent() - exc = _StreamErrorEvent( - "You have either run out of available resources or do not have an active Grok subscription.", - code="permission_denied", - ) - - summary = AIAgent._summarize_api_error(exc) - assert "active Grok subscription" in summary - - def test_fallback_still_raises_terminal_error_when_no_error_event(self): - """Streams that simply end without any terminal event (and no - ``error`` frame) must continue to raise the original - ``"did not emit a terminal response"`` RuntimeError so callers - can distinguish "stream truncated mid-flight" from "provider - rejected the call".""" - agent = self._make_agent() - - # Empty stream — no events at all - class _FakeStream: - def __iter__(self_inner): - return iter([]) - def close(self_inner): - return None - - mock_client = MagicMock() - mock_client.responses.create.return_value = _FakeStream() - - with pytest.raises(RuntimeError) as excinfo: - agent._run_codex_create_stream_fallback( - {"model": "grok-4.3", "instructions": "hi", "input": []}, - client=mock_client, - ) - - assert "did not emit a terminal response" in str(excinfo.value) diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 999db56c2..febef0a47 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -25,6 +25,8 @@ def _reset_logging_state(): """ hermes_logging._logging_initialized = False root = logging.getLogger() + prev_root_level = root.level + root.setLevel(logging.NOTSET) # Strip ALL RotatingFileHandlers — not just the ones we added — so that # handlers leaked from other test modules in the same xdist worker don't # pollute our counts. @@ -43,6 +45,7 @@ def _reset_logging_state(): if h not in pre_existing: root.removeHandler(h) h.close() + root.setLevel(prev_root_level) hermes_logging._logging_initialized = False hermes_logging.clear_session_context() @@ -355,6 +358,50 @@ class TestGatewayMode: assert "file msg" in content +class TestGuiMode: + """setup_logging(mode='gui') creates a filtered gui.log.""" + + def test_gui_log_created(self, hermes_home): + hermes_logging.setup_logging(hermes_home=hermes_home, mode="gui") + root = logging.getLogger() + + gui_handlers = [ + h for h in root.handlers + if isinstance(h, RotatingFileHandler) + and "gui.log" in getattr(h, "baseFilename", "") + ] + assert len(gui_handlers) == 1 + + def test_gui_log_created_after_cli_init(self, hermes_home): + hermes_logging.setup_logging(hermes_home=hermes_home, mode="cli") + hermes_logging.setup_logging(hermes_home=hermes_home, mode="gui") + + root = logging.getLogger() + gui_handlers = [ + h for h in root.handlers + if isinstance(h, RotatingFileHandler) + and "gui.log" in getattr(h, "baseFilename", "") + ] + assert len(gui_handlers) == 1 + + def test_gui_log_receives_only_gui_components(self, hermes_home): + hermes_logging.setup_logging(hermes_home=hermes_home, mode="gui") + + logging.getLogger("hermes_cli.web_server").info("dashboard online") + logging.getLogger("tui_gateway.ws").info("ws connected") + logging.getLogger("gateway.run").info("gateway event") + + for h in logging.getLogger().handlers: + h.flush() + + gui_log = hermes_home / "logs" / "gui.log" + assert gui_log.exists() + content = gui_log.read_text() + assert "dashboard online" in content + assert "ws connected" in content + assert "gateway event" not in content + + class TestSessionContext: """set_session_context / clear_session_context + _SessionFilter.""" @@ -560,6 +607,11 @@ class TestComponentPrefixes: def test_cron_prefix(self): assert ("cron",) == hermes_logging.COMPONENT_PREFIXES["cron"] + def test_gui_prefix(self): + prefixes = hermes_logging.COMPONENT_PREFIXES["gui"] + assert "hermes_cli.web_server" in prefixes + assert "tui_gateway" in prefixes + class TestSetupVerboseLogging: """setup_verbose_logging() adds a DEBUG-level console handler.""" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 99a8616e2..f083bf420 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2618,6 +2618,24 @@ class TestCompressionChainProjection: assert tip_row["ended_at"] is None # tip is still live assert tip_row["end_reason"] is None + def test_list_projection_uses_tip_cwd(self, db): + """Projected lineage rows should carry cwd from the live tip row. + + Without this, compressed conversations can lose workspace grouping + even after the continuation session persists its cwd. + """ + import time as _time + + self._build_compression_chain(db, _time.time() - 3600) + db.update_session_cwd("tip1", "/tmp/workspaces/tip") + db._conn.commit() + + sessions = db.list_sessions_rich(source="cli", limit=20) + tip_row = next(s for s in sessions if s["id"] == "tip1") + + assert tip_row["_lineage_root_id"] == "root1" + assert tip_row["cwd"] == "/tmp/workspaces/tip" + def test_list_without_projection_returns_raw_root(self, db): """project_compression_tips=False returns the raw parent-NULL root rows — useful for admin/debug UIs. diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4524fb88c..7b4ca867a 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -580,6 +580,10 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display(): def test_history_to_messages_renders_multimodal_content(): + # bb/gui preserves image URLs in the resume payload so the desktop + # renderer's extractEmbeddedImages can pull them back out and display + # the actual image instead of a placeholder. This also keeps the + # resume payload in sync with the cached message. history = [ { "role": "user", @@ -592,7 +596,7 @@ def test_history_to_messages_renders_multimodal_content(): ] assert server._history_to_messages(history) == [ - {"role": "user", "text": "look here\n[image]"}, + {"role": "user", "text": "look here\ndata:image/png;base64,abc"}, {"role": "assistant", "text": "saw it"}, ] @@ -630,7 +634,7 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch): monkeypatch.setattr( server, "_session_info", - lambda agent: {"model": "test", "tools": {}, "skills": {}}, + lambda agent, *a: {"model": "test", "tools": {}, "skills": {}}, ) monkeypatch.setattr( server, "_init_session", lambda sid, key, agent, history, cols=80: None @@ -1205,7 +1209,7 @@ def test_config_set_fast_updates_live_agent_and_config(monkeypatch): monkeypatch.setattr( server, "_write_config_key", lambda path, value: writes.append((path, value)) ) - monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) + monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"}) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) monkeypatch.setattr( "hermes_cli.models.resolve_fast_mode_overrides", @@ -1611,6 +1615,57 @@ def test_setup_status_reports_provider_config(monkeypatch): assert resp["result"]["provider_configured"] is False +def test_setup_runtime_check_rejects_empty_runtime_key(monkeypatch): + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda requested=None: { + "provider": "openrouter", + "api_key": "", + "source": "env/config", + }, + ) + + resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}}) + + assert resp["result"]["ok"] is False + assert resp["result"]["provider"] == "openrouter" + + +def test_setup_runtime_check_allows_no_key_custom_runtime(monkeypatch): + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda requested=None: { + "provider": "custom", + "api_key": "no-key-required", + "source": "env/config", + }, + ) + + resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}}) + + assert resp["result"]["ok"] is True + assert resp["result"]["provider"] == "custom" + + +def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypatch): + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda requested=None: { + "provider": "bedrock", + "api_key": "aws-sdk", + "source": "iam-role", + }, + ) + + resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}}) + + assert resp["result"]["ok"] is False + assert resp["result"]["provider"] == "bedrock" + + def test_complete_slash_includes_provider_alias(): resp = server.handle_request( {"id": "1", "method": "complete.slash", "params": {"text": "/pro"}} @@ -2042,7 +2097,7 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch): lambda cfg=None: {"helpful": "You are helpful."}, ) monkeypatch.setattr( - server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")} + server, "_session_info", lambda agent, *a: {"model": getattr(agent, "model", "?")} ) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) monkeypatch.setattr(server, "_write_config_key", lambda path, value: None) @@ -2079,7 +2134,7 @@ def test_session_compress_uses_compress_helper(monkeypatch): "_compress_session_history", lambda session, focus_topic=None, **_kw: (2, {"total": 42}), ) - monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) + monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"}) with patch("tui_gateway.server._emit") as emit: resp = server.handle_request( @@ -2111,7 +2166,7 @@ def test_session_compress_syncs_session_key_after_rotation(monkeypatch): "_compress_session_history", lambda session, focus_topic=None, **_kw: (2, {"total": 42}), ) - monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) + monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"}) restart_calls = [] monkeypatch.setattr( server, "_restart_slash_worker", lambda s: restart_calls.append(s) @@ -2889,6 +2944,83 @@ def test_prompt_submit_history_version_match_persists_normally(monkeypatch): server._sessions.pop("sid", None) +def test_prompt_submit_can_truncate_before_user_ordinal(monkeypatch): + """Desktop user-message edits should restart the turn from the edited user.""" + + seen = {} + + class _Agent: + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + seen["prompt"] = prompt + seen["history"] = conversation_history + return { + "final_response": "edited reply", + "messages": [ + *(conversation_history or []), + {"role": "user", "content": prompt}, + {"role": "assistant", "content": "edited reply"}, + ], + } + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + original_history = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "first reply"}, + {"role": "user", "content": "second"}, + {"role": "assistant", "content": "second reply"}, + ] + server._sessions["sid"] = _session(agent=_Agent(), history=original_history) + + class _StubDb: + def __init__(self): + self.replaced = [] + + def replace_messages(self, session_id, messages): + self.replaced.append((session_id, list(messages))) + + stub_db = _StubDb() + + try: + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_get_usage", lambda _a: {}) + monkeypatch.setattr(server, "render_message", lambda _t, _c: "") + monkeypatch.setattr(server, "_emit", lambda *a: None) + monkeypatch.setattr(server, "_get_db", lambda: stub_db) + + resp = server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": { + "session_id": "sid", + "text": "edited second", + "truncate_before_user_ordinal": 1, + }, + } + ) + assert resp.get("result"), f"got error: {resp.get('error')}" + + assert seen["prompt"] == "edited second" + assert seen["history"] == original_history[:2] + assert server._sessions["sid"]["history"] == [ + *original_history[:2], + {"role": "user", "content": "edited second"}, + {"role": "assistant", "content": "edited reply"}, + ] + assert server._sessions["sid"]["history_version"] == 2 + assert stub_db.replaced == [("session-key", original_history[:2])] + finally: + server._sessions.pop("sid", None) + + # --------------------------------------------------------------------------- # session.interrupt must only cancel pending prompts owned by the calling # session — it must not blast-resolve clarify/sudo/secret prompts on @@ -3162,7 +3294,7 @@ def test_mirror_slash_compress_does_not_prelock_history(monkeypatch): monkeypatch.setattr(server, "_compress_session_history", _fake_compress) monkeypatch.setattr(server, "_sync_session_key_after_compress", _fake_sync) - monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) + monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"}) monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args)) session = _session(running=False) @@ -3235,7 +3367,7 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None), ) - monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"}) + monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"}) monkeypatch.setattr(server, "_probe_credentials", lambda _a: None) monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) monkeypatch.setattr(server, "_emit", lambda *a, **kw: None) @@ -3336,7 +3468,7 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None), ) - monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"}) + monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"}) monkeypatch.setattr(server, "_probe_credentials", lambda _a: None) monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) monkeypatch.setattr(server, "_emit", lambda *a, **kw: None) @@ -3416,7 +3548,7 @@ def test_session_create_continues_when_state_db_is_unavailable(monkeypatch): monkeypatch.setattr(server, "_make_agent", lambda sid, key: _FakeAgent()) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) monkeypatch.setattr(server, "_get_db", lambda: None) - monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"}) + monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"}) monkeypatch.setattr(server, "_probe_credentials", lambda _a: None) monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) monkeypatch.setattr(server, "_emit", lambda *a, **kw: emits.append(a)) diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index e764ac0b4..6684e174f 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -27,6 +27,8 @@ def isolate_env(monkeypatch): "MISTRAL_API_KEY", "XAI_API_KEY", "XAI_STT_BASE_URL", + "ELEVENLABS_API_KEY", + "ELEVENLABS_STT_BASE_URL", ): monkeypatch.delenv(key, raising=False) @@ -117,6 +119,15 @@ class TestProviderSelectionGate: return_value={"XAI_API_KEY": "dotenv-secret"}): assert tt._get_provider({"enabled": True, "provider": "xai"}) == "xai" + def test_explicit_elevenlabs_sees_dotenv(self): + from tools import transcription_tools as tt + + with patch.object(tt, "_HAS_FASTER_WHISPER", False), \ + patch.object(tt, "_has_local_command", return_value=False), \ + patch("hermes_cli.config.load_env", + return_value={"ELEVENLABS_API_KEY": "dotenv-secret"}): + assert tt._get_provider({"enabled": True, "provider": "elevenlabs"}) == "elevenlabs" + def test_auto_detect_sees_dotenv_groq(self): """No local backend, no explicit provider — auto-detect should fall through to Groq when its key lives in dotenv only. Before the fix @@ -228,6 +239,33 @@ class TestTranscribeCallSitesReadDotenv: assert result["success"] is True assert captured["headers"]["Authorization"] == "Bearer xai-dotenv-key" + def test_transcribe_elevenlabs_forwards_dotenv_key(self): + from tools import transcription_tools as tt + + captured: dict = {} + + def fake_post(url, **kwargs): + captured["url"] = url + captured["headers"] = kwargs.get("headers", {}) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"text": "hello"} + return response + + def fake_get_env_value(name, default=None): + if name == "ELEVENLABS_API_KEY": + return "elevenlabs-dotenv-key" + return None + + with patch.object(tt, "get_env_value", side_effect=fake_get_env_value), \ + patch.object(tt, "_load_stt_config", return_value={}), \ + patch("requests.post", side_effect=fake_post), \ + patch("builtins.open", MagicMock()): + result = tt._transcribe_elevenlabs("/tmp/fake.mp3", "scribe_v2") + + assert result["success"] is True + assert captured["headers"]["xi-api-key"] == "elevenlabs-dotenv-key" + class TestEndToEndRegressionGuard: """End-to-end probe: patch ``hermes_cli.config.load_env`` to simulate diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index f3c0cf29c..434971e9a 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -64,6 +64,7 @@ def clean_env(monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("GROQ_API_KEY", raising=False) monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) monkeypatch.delenv("HERMES_LOCAL_STT_LANGUAGE", raising=False) @@ -1381,6 +1382,163 @@ class TestTranscribeAudioXAIDispatch: # ============================================================================ +# _transcribe_elevenlabs +# ============================================================================ + +class TestTranscribeElevenLabs: + def test_no_key(self, monkeypatch): + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + from tools.transcription_tools import _transcribe_elevenlabs + result = _transcribe_elevenlabs("/tmp/test.ogg", "scribe_v2") + assert result["success"] is False + assert "ELEVENLABS_API_KEY" in result["error"] + + def test_successful_transcription(self, monkeypatch, sample_ogg): + monkeypatch.setenv("ELEVENLABS_API_KEY", "eleven-test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"text": "hello from elevenlabs"} + + config = { + "elevenlabs": { + "language_code": "eng", + "tag_audio_events": True, + "diarize": True, + } + } + with patch("tools.transcription_tools._load_stt_config", return_value=config), \ + patch("requests.post", return_value=mock_response) as mock_post: + from tools.transcription_tools import _transcribe_elevenlabs + result = _transcribe_elevenlabs(sample_ogg, "scribe_v2") + + assert result["success"] is True + assert result["transcript"] == "hello from elevenlabs" + assert result["provider"] == "elevenlabs" + call_kwargs = mock_post.call_args.kwargs + assert call_kwargs["headers"]["xi-api-key"] == "eleven-test-key" + assert call_kwargs["data"]["model_id"] == "scribe_v2" + assert call_kwargs["data"]["language_code"] == "eng" + assert call_kwargs["data"]["tag_audio_events"] == "true" + assert call_kwargs["data"]["diarize"] == "true" + + def test_api_error_returns_failure(self, monkeypatch, sample_ogg): + monkeypatch.setenv("ELEVENLABS_API_KEY", "eleven-test-key") + + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.json.return_value = {"detail": {"message": "Invalid API key"}} + mock_response.text = '{"detail": {"message": "Invalid API key"}}' + + with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + patch("requests.post", return_value=mock_response): + from tools.transcription_tools import _transcribe_elevenlabs + result = _transcribe_elevenlabs(sample_ogg, "scribe_v2") + + assert result["success"] is False + assert "HTTP 401" in result["error"] + assert "Invalid API key" in result["error"] + + def test_empty_transcript_returns_failure(self, monkeypatch, sample_ogg): + monkeypatch.setenv("ELEVENLABS_API_KEY", "eleven-test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"text": " "} + + with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + patch("requests.post", return_value=mock_response): + from tools.transcription_tools import _transcribe_elevenlabs + result = _transcribe_elevenlabs(sample_ogg, "scribe_v2") + + assert result["success"] is False + assert "empty transcript" in result["error"] + + +# ============================================================================ +# _get_provider — ElevenLabs +# ============================================================================ + +class TestGetProviderElevenLabs: + """ElevenLabs-specific provider selection tests.""" + + def test_elevenlabs_when_key_set(self, monkeypatch): + monkeypatch.setenv("ELEVENLABS_API_KEY", "eleven-test") + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "elevenlabs"}) == "elevenlabs" + + def test_elevenlabs_explicit_no_key_returns_none(self, monkeypatch): + """Explicit elevenlabs with no key returns none — no cross-provider fallback.""" + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "elevenlabs"}) == "none" + + def test_auto_detect_elevenlabs_after_xai(self, monkeypatch): + """Auto-detect: elevenlabs is tried after xai when all above are unavailable.""" + monkeypatch.delenv("GROQ_API_KEY", raising=False) + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.setenv("ELEVENLABS_API_KEY", "eleven-test") + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("tools.transcription_tools._has_local_command", return_value=False), \ + patch("tools.transcription_tools._HAS_OPENAI", False), \ + patch("tools.transcription_tools._HAS_MISTRAL", False): + from tools.transcription_tools import _get_provider + assert _get_provider({}) == "elevenlabs" + + def test_auto_detect_xai_preferred_over_elevenlabs(self, monkeypatch): + """Auto-detect: xai is preferred over elevenlabs.""" + monkeypatch.setenv("XAI_API_KEY", "xai-test") + monkeypatch.setenv("ELEVENLABS_API_KEY", "eleven-test") + with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("tools.transcription_tools._has_local_command", return_value=False), \ + patch("tools.transcription_tools._HAS_OPENAI", False), \ + patch("tools.transcription_tools._HAS_MISTRAL", False): + from tools.transcription_tools import _get_provider + assert _get_provider({}) == "xai" + + +# ============================================================================ +# transcribe_audio — ElevenLabs dispatch +# ============================================================================ + +class TestTranscribeAudioElevenLabsDispatch: + def test_dispatches_to_elevenlabs(self, sample_ogg): + with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "elevenlabs"}), \ + patch("tools.transcription_tools._get_provider", return_value="elevenlabs"), \ + patch("tools.transcription_tools._transcribe_elevenlabs", + return_value={"success": True, "transcript": "hi", "provider": "elevenlabs"}) as mock_elevenlabs: + from tools.transcription_tools import transcribe_audio + result = transcribe_audio(sample_ogg) + + assert result["success"] is True + assert result["provider"] == "elevenlabs" + mock_elevenlabs.assert_called_once() + + def test_config_elevenlabs_model_used(self, sample_ogg): + config = {"provider": "elevenlabs", "elevenlabs": {"model_id": "scribe_v1"}} + with patch("tools.transcription_tools._load_stt_config", return_value=config), \ + patch("tools.transcription_tools._get_provider", return_value="elevenlabs"), \ + patch("tools.transcription_tools._transcribe_elevenlabs", + return_value={"success": True, "transcript": "hi"}) as mock_elevenlabs: + from tools.transcription_tools import transcribe_audio + transcribe_audio(sample_ogg, model=None) + + assert mock_elevenlabs.call_args[0][1] == "scribe_v1" + + def test_model_override_passed_to_elevenlabs(self, sample_ogg): + with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + patch("tools.transcription_tools._get_provider", return_value="elevenlabs"), \ + patch("tools.transcription_tools._transcribe_elevenlabs", + return_value={"success": True, "transcript": "hi"}) as mock_elevenlabs: + from tools.transcription_tools import transcribe_audio + transcribe_audio(sample_ogg, model="scribe_v2") + + assert mock_elevenlabs.call_args[0][1] == "scribe_v2" + + # Shell safety — shlex.split on auto-detected templates # ============================================================================ class TestShellSafety: diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index f2f68efbf..2c20e77a1 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -307,7 +307,7 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch): def get_messages_as_conversation(self, _sid, include_ancestors=False): return [ {"role": "user", "content": "hello"}, - {"role": "assistant", "content": "yo"}, + {"role": "assistant", "content": "yo", "reasoning": "thoughts"}, {"role": "tool", "content": "searched"}, {"role": "assistant", "content": " "}, {"role": "assistant", "content": None}, @@ -317,7 +317,7 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch): monkeypatch.setattr(server, "_get_db", lambda: _DB()) monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object()) monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) - monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "test/model"}) + monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"}) resp = server.handle_request( { @@ -331,11 +331,99 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch): assert resp["result"]["message_count"] == 3 assert resp["result"]["messages"] == [ {"role": "user", "text": "hello"}, - {"role": "assistant", "text": "yo"}, + {"role": "assistant", "text": "yo", "reasoning": "thoughts"}, {"role": "tool", "name": "tool", "context": ""}, ] +def test_session_resume_handles_multimodal_list_content(server, monkeypatch): + """A user message persisted with list-shaped multimodal content used to + crash session resume with ``'list' object has no attribute 'strip'``.""" + + multimodal_user = { + "role": "user", + "content": [ + {"type": "text", "text": "describe this"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,AAAA"}, + }, + ], + } + text_only_assistant = {"role": "assistant", "content": "ok"} + + class _DB: + def get_session(self, _sid): + return {"id": "20260502_000000_listcontent"} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages_as_conversation(self, _sid, include_ancestors=False): + return [multimodal_user, text_only_assistant] + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object()) + monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) + monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"}) + + resp = server.handle_request( + { + "id": "r1", + "method": "session.resume", + "params": {"session_id": "20260502_000000_listcontent", "cols": 100}, + } + ) + + assert "error" not in resp + assert resp["result"]["message_count"] == 2 + # The image_url part is preserved as a raw data URL inside the text so + # the desktop renderer (which extracts embedded images) sees the same + # content the optimistic local cache returns. Otherwise the inline + # image flashes during initial cache hydration and then vanishes when + # the resume payload overwrites it with cleaned text. + assert resp["result"]["messages"] == [ + { + "role": "user", + "text": "describe this\ndata:image/png;base64,AAAA", + }, + {"role": "assistant", "text": "ok"}, + ] + + +def test_make_agent_accepts_list_system_prompt(server, monkeypatch): + captured = {} + + class _Agent: + def __init__(self, **kwargs): + captured.update(kwargs) + self.model = kwargs.get("model", "") + + monkeypatch.setitem(sys.modules, "run_agent", types.SimpleNamespace(AIAgent=_Agent)) + monkeypatch.setitem( + sys.modules, + "hermes_cli.runtime_provider", + types.SimpleNamespace( + resolve_runtime_provider=lambda **_kwargs: { + "provider": "test", + "base_url": None, + "api_key": None, + "api_mode": None, + } + ), + ) + monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"system_prompt": ["one", "two"]}}) + monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test/model", "test")) + monkeypatch.setattr(server, "_get_db", lambda: None) + + server._make_agent("sid", "session-key", session_id="session-key") + + assert captured["ephemeral_system_prompt"] == "one\ntwo" + + # ── Config I/O ─────────────────────────────────────────────────────── diff --git a/tests/tui_gateway/test_review_summary_callback.py b/tests/tui_gateway/test_review_summary_callback.py index 56ca2d494..2c6d3cbeb 100644 --- a/tests/tui_gateway/test_review_summary_callback.py +++ b/tests/tui_gateway/test_review_summary_callback.py @@ -54,7 +54,7 @@ def test_init_session_attaches_background_review_callback(server, monkeypatch): monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object()) monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None) monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None) - monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"}) + monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"}) monkeypatch.setattr(server, "_load_show_reasoning", lambda: False) monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all") @@ -106,7 +106,7 @@ def test_review_summary_callback_survives_agent_without_attribute(server, monkey monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object()) monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None) monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None) - monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"}) + monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"}) monkeypatch.setattr(server, "_load_show_reasoning", lambda: False) monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all") monkeypatch.setattr(server, "_emit", lambda *a, **kw: None) diff --git a/tools/process_registry.py b/tools/process_registry.py index 6679d7402..4e8da5b7c 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -633,7 +633,8 @@ class ProcessRegistry: try: if not _IS_WINDOWS: try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # windows-footgun: ok — guarded by _IS_WINDOWS check above + kill_signal = getattr(signal, "SIGKILL", signal.SIGTERM) + os.killpg(os.getpgid(proc.pid), kill_signal) # windows-footgun: ok - guarded by _IS_WINDOWS above except (ProcessLookupError, PermissionError, OSError): proc.kill() else: diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 4794d4568..492f04cb8 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -11,6 +11,7 @@ Provides speech-to-text transcription with six providers: - **mistral** — Mistral Voxtral Transcribe API, requires ``MISTRAL_API_KEY``. - **xai** — xAI Grok STT API, requires ``XAI_API_KEY``. High accuracy, Inverse Text Normalization, diarization, 21 languages. + - **elevenlabs** — ElevenLabs Scribe API, requires ``ELEVENLABS_API_KEY``. Used by the messaging gateway to automatically transcribe voice messages sent by users on Telegram, Discord, WhatsApp, Slack, and Signal. @@ -88,6 +89,7 @@ DEFAULT_LOCAL_STT_LANGUAGE = "en" DEFAULT_STT_MODEL = os.getenv("STT_OPENAI_MODEL", "whisper-1") DEFAULT_GROQ_STT_MODEL = os.getenv("STT_GROQ_MODEL", "whisper-large-v3-turbo") DEFAULT_MISTRAL_STT_MODEL = os.getenv("STT_MISTRAL_MODEL", "voxtral-mini-latest") +DEFAULT_ELEVENLABS_STT_MODEL = os.getenv("STT_ELEVENLABS_MODEL", "scribe_v2") LOCAL_STT_COMMAND_ENV = "HERMES_LOCAL_STT_COMMAND" LOCAL_STT_LANGUAGE_ENV = "HERMES_LOCAL_STT_LANGUAGE" COMMON_LOCAL_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin") @@ -95,6 +97,7 @@ COMMON_LOCAL_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin") GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1") OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1") XAI_STT_BASE_URL = os.getenv("XAI_STT_BASE_URL", "https://api.x.ai/v1") +ELEVENLABS_STT_BASE_URL = os.getenv("ELEVENLABS_STT_BASE_URL", "https://api.elevenlabs.io/v1") SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".aac", ".flac"} LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"} @@ -810,9 +813,19 @@ def _get_provider(stt_config: dict) -> str: ) return "none" + if provider == "elevenlabs": + if get_env_value("ELEVENLABS_API_KEY"): + return "elevenlabs" + logger.warning( + "STT provider 'elevenlabs' configured but ELEVENLABS_API_KEY not set" + ) + return "none" + return provider # Unknown — let it fail downstream - # --- Auto-detect (no explicit provider): local > groq > openai > mistral > xai --- + # --- Auto-detect (no explicit provider): local > groq > openai > xai > elevenlabs - + # mistral is intentionally skipped while `mistralai` is quarantined on + # PyPI (malicious 2.4.6 release on 2026-05-12). if _HAS_FASTER_WHISPER: return "local" @@ -841,6 +854,9 @@ def _get_provider(stt_config: dict) -> str: return "xai" except Exception: pass + if get_env_value("ELEVENLABS_API_KEY"): + logger.info("No local STT available, using ElevenLabs Scribe STT API") + return "elevenlabs" return "none" @@ -1505,6 +1521,92 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": f"xAI STT transcription failed: {e}"} +# --------------------------------------------------------------------------- +# Provider: ElevenLabs (Scribe STT API) +# --------------------------------------------------------------------------- + + +def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: + """Transcribe using ElevenLabs Scribe STT API.""" + api_key = get_env_value("ELEVENLABS_API_KEY") + if not api_key: + return {"success": False, "transcript": "", "error": "ELEVENLABS_API_KEY not set"} + + stt_config = _load_stt_config() + elevenlabs_config = stt_config.get("elevenlabs", {}) + base_url = str( + elevenlabs_config.get("base_url") + or get_env_value("ELEVENLABS_STT_BASE_URL") + or ELEVENLABS_STT_BASE_URL + ).strip().rstrip("/") + language_code = str(elevenlabs_config.get("language_code") or "").strip() + tag_audio_events = is_truthy_value(elevenlabs_config.get("tag_audio_events", False)) + diarize = is_truthy_value(elevenlabs_config.get("diarize", False)) + + try: + import requests + + data: Dict[str, str] = { + "model_id": model_name, + "tag_audio_events": "true" if tag_audio_events else "false", + "diarize": "true" if diarize else "false", + } + if language_code: + data["language_code"] = language_code + + with open(file_path, "rb") as audio_file: + response = requests.post( + f"{base_url}/speech-to-text", + headers={"xi-api-key": api_key}, + files={"file": (Path(file_path).name, audio_file)}, + data=data, + timeout=120, + ) + + if response.status_code != 200: + detail = "" + try: + err_body = response.json() + error_value = err_body.get("detail") or err_body.get("error") + if isinstance(error_value, dict): + detail = str(error_value.get("message") or error_value) + elif error_value: + detail = str(error_value) + else: + detail = response.text[:300] + except Exception: + detail = response.text[:300] + return { + "success": False, + "transcript": "", + "error": f"ElevenLabs STT API error (HTTP {response.status_code}): {detail}", + } + + result = response.json() + transcript_text = _extract_transcript_text(result) + if not transcript_text: + return { + "success": False, + "transcript": "", + "error": "ElevenLabs STT returned empty transcript", + } + + logger.info( + "Transcribed %s via ElevenLabs Scribe (%s, %d chars)", + Path(file_path).name, + model_name, + len(transcript_text), + ) + + return {"success": True, "transcript": transcript_text, "provider": "elevenlabs"} + + except PermissionError: + return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"} + except Exception as e: + logger.error("ElevenLabs STT transcription failed: %s", e, exc_info=True) + return {"success": False, "transcript": "", "error": f"ElevenLabs STT transcription failed: {e}"} + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -1516,7 +1618,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A Provider priority: 1. User config (``stt.provider`` in config.yaml) - 2. Auto-detect: local faster-whisper (free) > Groq (free tier) > OpenAI (paid) + 2. Auto-detect: local > Groq > OpenAI > Mistral > xAI > ElevenLabs Args: file_path: Absolute path to the audio file to transcribe. @@ -1578,6 +1680,11 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A model_name = model or "grok-stt" return _transcribe_xai(file_path, model_name) + if provider == "elevenlabs": + elevenlabs_cfg = stt_config.get("elevenlabs", {}) + model_name = model or elevenlabs_cfg.get("model_id", DEFAULT_ELEVENLABS_STT_MODEL) + return _transcribe_elevenlabs(file_path, model_name) + # User-declared command-type provider # (``stt.providers.<name>: type: command``). Fires after the built-in # elif chain — built-in names short-circuit upstream so a user's @@ -1628,7 +1735,8 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A "No STT provider available. Install faster-whisper for free local " f"transcription, configure {LOCAL_STT_COMMAND_ENV} or install a local whisper CLI, " "set GROQ_API_KEY for free Groq Whisper, set MISTRAL_API_KEY for Mistral " - "Voxtral Transcribe, configure xAI OAuth or set XAI_API_KEY for xAI Grok STT, or set VOICE_TOOLS_OPENAI_KEY " + "Voxtral Transcribe, configure xAI OAuth or set XAI_API_KEY for xAI Grok STT, " + "set ELEVENLABS_API_KEY for ElevenLabs Scribe, or set VOICE_TOOLS_OPENAI_KEY " "or OPENAI_API_KEY for the OpenAI Whisper API." ), } diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4af8e2887..7c83c915c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2,6 +2,7 @@ import atexit import concurrent.futures import contextvars import copy +import inspect import json import logging import os @@ -586,10 +587,7 @@ def _start_agent_build(sid: str, session: dict) -> None: _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) _notify_session_boundary("on_session_reset", key) - info = _session_info(agent) - warn = _probe_credentials(agent) - if warn: - info["credential_warning"] = warn + info = _session_info(agent, current) cfg_warn = _probe_config_health(_load_cfg()) if cfg_warn: info["config_warning"] = cfg_warn @@ -644,6 +642,87 @@ def _normalize_completion_path(path_part: str) -> str: return expanded +def _completion_cwd(params: dict | None = None) -> str: + raw = ( + (params or {}).get("cwd") + or _sessions.get((params or {}).get("session_id") or "", {}).get("cwd") + or os.environ.get("TERMINAL_CWD") + or os.getcwd() + ) + try: + resolved = os.path.abspath(os.path.expanduser(str(raw))) + if os.path.isdir(resolved): + return resolved + except Exception: + pass + return os.getcwd() + + +def _git_branch_for_cwd(cwd: str) -> str: + try: + result = subprocess.run( + ["git", "-C", cwd, "branch", "--show-current"], + capture_output=True, + text=True, + timeout=1.5, + check=False, + ) + if result.returncode == 0: + branch = result.stdout.strip() + if branch: + return branch + head = subprocess.run( + ["git", "-C", cwd, "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + timeout=1.5, + check=False, + ) + return head.stdout.strip() if head.returncode == 0 else "" + except Exception: + return "" + + +def _session_cwd(session: dict | None) -> str: + if session and session.get("cwd"): + return str(session["cwd"]) + return _completion_cwd() + + +def _register_session_cwd(session: dict | None) -> None: + if not session: + return + try: + from tools.terminal_tool import register_task_env_overrides + + register_task_env_overrides( + session["session_key"], {"cwd": _session_cwd(session)} + ) + except Exception: + pass + + +def _set_session_cwd(session: dict, cwd: str) -> str: + resolved = os.path.abspath(os.path.expanduser(str(cwd))) + if not os.path.isdir(resolved): + raise ValueError(f"working directory does not exist: {cwd}") + session["cwd"] = resolved + _register_session_cwd(session) + db = _get_db() + if db is not None: + try: + db.update_session_cwd(session.get("session_key", ""), resolved) + except Exception: + logger.debug("failed to persist session cwd", exc_info=True) + try: + from tools.terminal_tool import cleanup_vm + + cleanup_vm(session["session_key"]) + except Exception: + pass + return resolved + + # ── Config I/O ──────────────────────────────────────────────────────── @@ -1174,7 +1253,7 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: api_mode=result.api_mode, ) _restart_slash_worker(session) - _emit("session.info", sid, _session_info(agent)) + _emit("session.info", sid, _session_info(agent, session)) os.environ["HERMES_MODEL"] = result.new_model os.environ["HERMES_INFERENCE_MODEL"] = result.new_model @@ -1424,7 +1503,22 @@ def _current_profile_name() -> str: return "default" -def _session_info(agent) -> dict: +# Monotonic GUI<->backend contract version. The desktop app refuses to drive a +# backend reporting less than its required value (or none at all — a pre-GUI +# checkout), surfacing a one-click "update to align" prompt instead of failing +# cryptically downstream. Bump whenever the desktop's backend contract changes. +DESKTOP_BACKEND_CONTRACT = 1 + + +def _session_info(agent, session: dict | None = None) -> dict: + if session is None: + for candidate in _sessions.values(): + if candidate.get("agent") is agent: + session = candidate + break + cwd = _session_cwd(session) + cfg_personality = ((_load_cfg().get("display") or {}).get("personality") or "") + personality = (session or {}).get("personality", cfg_personality) reasoning_config = getattr(agent, "reasoning_config", None) reasoning_effort = "" if ( @@ -1440,7 +1534,11 @@ def _session_info(agent) -> dict: "fast": service_tier == "priority", "tools": {}, "skills": {}, - "cwd": os.getenv("TERMINAL_CWD", os.getcwd()), + "cwd": cwd, + "branch": _git_branch_for_cwd(cwd), + "personality": str(personality or ""), + "running": bool((session or {}).get("running")), + "desktop_contract": DESKTOP_BACKEND_CONTRACT, "version": "", "release_date": "", "update_behind": None, @@ -1489,6 +1587,9 @@ def _session_info(agent) -> dict: info["update_command"] = recommended_update_command() except Exception: pass + warn = _probe_credentials(agent) + if warn: + info["credential_warning"] = warn return info @@ -1645,7 +1746,7 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): - payload = {"tool_id": tool_call_id, "name": name} + payload = {"tool_id": tool_call_id, "name": name, "args": args} session = _sessions.get(sid) snapshot = None started_at = None @@ -1655,6 +1756,10 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result duration_s = time.time() - started_at if started_at else None if duration_s is not None: payload["duration_s"] = duration_s + try: + payload["result"] = json.loads(result) + except Exception: + payload["result"] = result summary = _tool_summary(name, result, duration_s) if summary: payload["summary"] = summary @@ -1698,7 +1803,9 @@ def _on_tool_progress( if not _tool_progress_enabled(sid): return if event_type == "tool.started" and name: - _emit("tool.progress", sid, {"name": name, "preview": preview or ""}) + # `_on_tool_start` already emits the authoritative `tool.start` with + # the stable tool id and args. Emitting another id-less progress row + # here makes the desktop live view diverge from hydrated history. return if event_type == "reasoning.available" and preview: payload: dict[str, object] = {"text": str(preview)} @@ -1870,8 +1977,19 @@ def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str return name, _render_personality_prompt(personalities[name]) +def _prompt_text(value) -> str: + """Normalize config prompt values from YAML before handing them to AIAgent.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + return "\n".join(str(item).strip() for item in value if str(item).strip()) + return str(value).strip() + + def _apply_personality_to_session( - sid: str, session: dict, new_prompt: str + sid: str, session: dict, new_prompt: str, personality: str = "" ) -> tuple[bool, dict | None]: """Apply a personality change to an existing session without resetting history. @@ -1889,6 +2007,7 @@ def _apply_personality_to_session( """ if not session: return False, None + session["personality"] = personality agent = session.get("agent") if agent: @@ -1976,6 +2095,133 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: } +def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict: + kwargs = _background_agent_kwargs(agent, task_id) + kwargs.update( + { + "enabled_toolsets": ["terminal", "file"], + "session_db": None, + "skip_memory": True, + } + ) + return kwargs + + +def _preview_restart_history(session: dict, max_messages: int = 24, max_tool_chars: int = 1200) -> list[dict]: + """Distill the parent session's recent history into a context the + ephemeral preview-restart agent can actually use. + + The restart agent has no idea what app the user was building, what + server they ran, what cwd was active, or which port belongs to which + project. Without this, it would take the bare URL + console logs and + guess — usually starting the wrong thing. + + We keep the last ``max_messages`` messages from the parent session so + the restart agent sees recent user prompts, assistant replies, and + most importantly any terminal/tool calls. Tool result payloads are + truncated so we don't blow the context window with file dumps. + """ + try: + with session["history_lock"]: + history = list(session.get("history", []) or []) + except Exception: + history = list(session.get("history", []) or []) + + if not history: + return [] + + # Anchor on the last user turn so we always include at least the most + # recent request and the assistant/tool work that followed it. Then + # extend backwards up to max_messages so we capture the prior context. + last_user_idx = None + for idx in range(len(history) - 1, -1, -1): + if history[idx].get("role") == "user": + last_user_idx = idx + break + + start = max(0, len(history) - max_messages) + if last_user_idx is not None: + start = min(start, last_user_idx) + + trimmed: list[dict] = [] + for msg in history[start:]: + if not isinstance(msg, dict): + continue + role = msg.get("role") + if role not in ("user", "assistant", "tool", "system"): + continue + + copy = {k: v for k, v in msg.items() if k != "reasoning"} + # Truncate heavy tool outputs so a single 50KB file read doesn't + # crowd out the rest of the context. + if role == "tool": + content = copy.get("content") + if isinstance(content, str) and len(content) > max_tool_chars: + copy["content"] = ( + content[:max_tool_chars] + + f"\n... (truncated, original {len(content)} chars)" + ) + trimmed.append(copy) + + return trimmed + + +def _preview_tool_result_preview(name: str, result: str) -> str: + try: + data = json.loads(result) + except Exception: + return "" + + if not isinstance(data, dict): + return "" + + if name == "terminal": + output = str(data.get("output") or "").strip() + exit_code = data.get("exit_code") + if output: + return output[-1200:] + if data.get("session_id"): + return f"Background process started: {data.get('session_id')}" + if exit_code is not None: + return f"terminal exited with code {exit_code}" + + return str(data.get("error") or "").strip()[:1200] + + +def _preview_restart_callbacks(parent: str, task_id: str) -> dict: + started_at: dict[str, float] = {} + + def progress(message: str, level: str = "info") -> None: + text = str(message or "").strip() + if text: + _emit("preview.restart.progress", parent, {"task_id": task_id, "level": level, "text": text}) + + def tool_start(tool_call_id: str, name: str, args: dict) -> None: + started_at[tool_call_id] = time.time() + ctx = _tool_ctx(name, args) + progress(f"Running {name}{f': {ctx}' if ctx else ''}") + + def tool_complete(tool_call_id: str, name: str, _args: dict, result: str) -> None: + duration_s = time.time() - started_at.get(tool_call_id, time.time()) + summary = _tool_summary(name, result, duration_s) or f"Finished {name}{f' in {_fmt_tool_duration(duration_s)}' if duration_s else ''}" + output = _preview_tool_result_preview(name, result) + progress(summary + (f"\n{output}" if output else "")) + + def tool_progress(event_type: str, name: str | None = None, preview: str | None = None, **_kwargs) -> None: + if preview: + progress(str(preview)) + elif name: + progress(f"{event_type.replace('.', ' ')}: {name}") + + return { + "tool_start_callback": tool_start, + "tool_complete_callback": tool_complete, + "tool_progress_callback": tool_progress, + "tool_gen_callback": lambda name: progress(f"Preparing {name}"), + "status_callback": lambda kind, text=None: progress(text if text is not None else kind), + } + + def _reset_session_agent(sid: str, session: dict) -> dict: tokens = _set_session_context(session["session_key"]) try: @@ -1995,7 +2241,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict: with session["history_lock"]: session["history"] = [] session["history_version"] = int(session.get("history_version", 0)) + 1 - info = _session_info(new_agent) + info = _session_info(new_agent, session) _emit("session.info", sid, info) _restart_slash_worker(session) return info @@ -2020,7 +2266,7 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): cfg = _load_cfg() agent_cfg = cfg.get("agent") or {} - system_prompt = (agent_cfg.get("system_prompt", "") or "").strip() + system_prompt = _prompt_text(agent_cfg.get("system_prompt", "")) startup_skills = _parse_tui_skills_env() if startup_skills: from agent.skill_commands import build_preloaded_skills_prompt @@ -2085,6 +2331,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "running": False, "attached_images": [], "image_counter": 0, + "cwd": _completion_cwd(), "cols": cols, "slash_worker": None, "show_reasoning": _load_show_reasoning(), @@ -2095,6 +2342,17 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): # session (stdio for Ink, JSON-RPC WS for the dashboard sidebar). "transport": current_transport() or _stdio_transport, } + db = _get_db() + if db is not None: + row = db.get_session(key) + if row and row.get("cwd"): + _sessions[sid]["cwd"] = row["cwd"] + else: + try: + db.update_session_cwd(key, _sessions[sid]["cwd"]) + except Exception: + logger.debug("failed to persist resumed session cwd", exc_info=True) + _register_session_cwd(_sessions[sid]) try: _sessions[sid]["slash_worker"] = _SlashWorker( key, getattr(agent, "model", _resolve_model()) @@ -2125,7 +2383,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): _wire_callbacks(sid) _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) _notify_session_boundary("on_session_reset", key) - _emit("session.info", sid, _session_info(agent)) + _emit("session.info", sid, _session_info(agent, _sessions[sid])) def _new_session_key() -> str: @@ -2133,7 +2391,7 @@ def _new_session_key() -> str: def _with_checkpoints(session, fn): - return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) + return fn(session["agent"]._checkpoint_mgr, _session_cwd(session)) def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str: @@ -2214,6 +2472,93 @@ def _content_display_text(content: Any) -> str: return str(content) +def _coerce_message_text(content: Any) -> str: + """Render ``message['content']`` as a plain string for transport. + + Provider-side, ``content`` may be a string (most common), a list of + multimodal parts (e.g. ``[{"type": "text", "text": "..."}, + {"type": "image_url", "image_url": {...}}]``), or a single structured + dict. Calling ``.strip()`` on a list raises ``'list' object has no + attribute 'strip'`` and breaks session resume entirely. + + Image parts (``image_url``) are preserved by appending the underlying + URL (data: or http:) into the text. The desktop renderer pulls these + back out via ``extractEmbeddedImages`` so the user sees the image + instead of the URL — and it stops the resume payload from disagreeing + with the cached message (which would otherwise cause the inline image + to flash, then disappear when the resume payload overwrites the cache). + + Other structured dict shapes (audio, unknown types) fall back to a + bracketed placeholder so resume doesn't drop the message entirely. + """ + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, (int, float)): + return str(content) + if isinstance(content, list): + chunks: list[str] = [] + for part in content: + if isinstance(part, str): + chunks.append(part) + continue + if not isinstance(part, dict): + continue + text = part.get("text") + if isinstance(text, str): + chunks.append(text) + continue + kind = part.get("type") + if kind in {"text", "input_text", "output_text"}: + t = part.get("text") or part.get("content") or "" + if t: + chunks.append(str(t)) + continue + if kind in {"image_url", "input_image", "image"}: + image_url = part.get("image_url") + url = "" + if isinstance(image_url, dict): + candidate = image_url.get("url") + if isinstance(candidate, str): + url = candidate + elif isinstance(image_url, str): + url = image_url + if url: + chunks.append(f"\n{url}") + else: + chunks.append("\n[image]") + continue + if kind in {"input_audio", "audio"}: + chunks.append("\n[audio]") + continue + if kind: + chunks.append(f"\n[{kind}]") + return "".join(chunks) + if isinstance(content, dict): + kind = content.get("type") + if kind in {"text", "input_text", "output_text"}: + return str(content.get("text") or content.get("content") or "") + if kind in {"image_url", "input_image", "image"}: + image_url = content.get("image_url") + url = "" + if isinstance(image_url, dict): + candidate = image_url.get("url") + if isinstance(candidate, str): + url = candidate + elif isinstance(image_url, str): + url = image_url + return url or "[image]" + if kind in {"input_audio", "audio"}: + return "[audio]" + if kind: + return f"[{kind}]" + if "text" in content: + return str(content.get("text") or "") + return "[structured content]" + return str(content) + + def _history_to_messages(history: list[dict]) -> list[dict]: messages = [] tool_call_args = {} @@ -2224,7 +2569,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]: role = m.get("role") if role not in {"user", "assistant", "tool", "system"}: continue - content_text = _content_display_text(m.get("content")) + content_text = _coerce_message_text(m.get("content")) if role == "assistant" and m.get("tool_calls"): for tc in m["tool_calls"]: fn = tc.get("function", {}) @@ -2248,11 +2593,75 @@ def _history_to_messages(history: list[dict]) -> list[dict]: continue if not content_text.strip(): continue - messages.append({"role": role, "text": content_text}) + msg = {"role": role, "text": content_text} + if role == "assistant": + for key in ( + "reasoning", + "reasoning_content", + "reasoning_details", + "codex_reasoning_items", + ): + if key in m and m.get(key) is not None: + msg[key] = m.get(key) + messages.append(msg) return messages +def _coerce_seed_history(value: Any) -> list[dict]: + if not isinstance(value, list): + return [] + + history = [] + for item in value: + if not isinstance(item, dict): + continue + + role = item.get("role") + if role not in ("user", "assistant", "system"): + continue + + content = item.get("content") + if content is None: + content = item.get("text") + if not isinstance(content, str) or not content.strip(): + continue + + history.append({"role": role, "content": content}) + + return history + + +def _content_display_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, (int, float)): + return str(content) + if isinstance(content, list): + parts = [] + for part in content: + text = _content_display_text(part).strip() + if text: + parts.append(text) + return "\n".join(parts) + if isinstance(content, dict): + kind = content.get("type") + if kind in {"text", "input_text", "output_text"}: + return str(content.get("text") or content.get("content") or "") + if kind in {"image_url", "input_image", "image"}: + return "[image]" + if kind in {"input_audio", "audio"}: + return "[audio]" + if kind: + return f"[{kind}]" + if "text" in content: + return str(content.get("text") or "") + return "[structured content]" + return str(content) + + def _inflight_text(value: Any) -> str: return _content_display_text(value).strip() @@ -2309,6 +2718,8 @@ def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] key = _new_session_key() cols = int(params.get("cols", 80)) + history = _coerce_seed_history(params.get("messages")) + title = str(params.get("title") or "").strip() _enable_gateway_prompts() ready = threading.Event() @@ -2322,13 +2733,14 @@ def _(rid, params: dict) -> dict: "cols": cols, "created_at": now, "edit_snapshots": {}, - "history": [], + "history": history, "history_lock": threading.Lock(), "history_version": 0, "image_counter": 0, + "cwd": _completion_cwd(params), "inflight_turn": None, "last_active": now, - "pending_title": None, + "pending_title": title or None, "running": False, "session_key": key, "show_reasoning": _load_show_reasoning(), @@ -2337,6 +2749,18 @@ def _(rid, params: dict) -> dict: "tool_started_at": {}, "transport": current_transport() or _stdio_transport, } + _register_session_cwd(_sessions[sid]) + db = _get_db() + if db is not None: + try: + db.create_session( + key, + source="tui", + model=_resolve_model(), + cwd=_sessions[sid]["cwd"], + ) + except Exception: + logger.debug("failed to pre-create desktop session row", exc_info=True) # Return the lightweight session immediately so Ink can paint the composer # + skeleton panel, then build the real AIAgent just after this response is @@ -2355,11 +2779,15 @@ def _(rid, params: dict) -> dict: rid, { "session_id": sid, + "stored_session_id": key, + "message_count": len(history), + "messages": _history_to_messages(history), "info": { "model": _resolve_model(), "tools": {}, "skills": {}, - "cwd": os.getenv("TERMINAL_CWD", os.getcwd()), + "cwd": _sessions[sid]["cwd"], + "branch": _git_branch_for_cwd(_sessions[sid]["cwd"]), "lazy": True, "profile_name": _current_profile_name(), }, @@ -2496,11 +2924,35 @@ def _(rid, params: dict) -> dict: "resumed": target, "message_count": len(messages), "messages": messages, - "info": _session_info(agent), + "info": _session_info(agent, _sessions.get(sid)), }, ) +@method("session.cwd.set") +def _(rid, params: dict) -> dict: + session, err = _sess_nowait(params, rid) + if err: + return err + if session.get("running"): + return _err(rid, 4009, "session busy") + raw = str(params.get("cwd", "") or "").strip() + if not raw: + return _err(rid, 4016, "cwd required") + try: + cwd = _set_session_cwd(session, raw) + except ValueError as e: + return _err(rid, 4017, str(e)) + agent = session.get("agent") + info = _session_info(agent, session) if agent is not None else { + "cwd": cwd, + "branch": _git_branch_for_cwd(cwd), + "lazy": True, + } + _emit("session.info", params.get("session_id", ""), info) + return _ok(rid, info) + + def _session_pending_kind(sid: str) -> str: for rid, (owner_sid, _ev) in list(_pending.items()): if owner_sid != sid: @@ -2933,7 +3385,7 @@ def _(rid, params: dict) -> dict: summary = summarize_manual_compression( before_messages, messages, before_tokens, after_tokens ) - info = _session_info(agent) + info = _session_info(agent, session) _emit("session.info", sid, info) return _ok( rid, @@ -3039,7 +3491,11 @@ def _(rid, params: dict) -> dict: else f"{current} (branch)" ) db.create_session( - new_key, source="tui", model=_resolve_model(), parent_session_id=old_key + new_key, + source="tui", + model=_resolve_model(), + parent_session_id=old_key, + cwd=_session_cwd(session), ) for msg in history: db.append_message( @@ -3351,12 +3807,35 @@ def _(rid, params: dict) -> dict: @method("prompt.submit") def _(rid, params: dict) -> dict: sid, text = params.get("session_id", ""), params.get("text", "") + truncate_user_ordinal = params.get("truncate_before_user_ordinal") session, err = _sess_nowait(params, rid) if err: return err + # Re-bind to the current client transport for this request. This keeps + # streaming events on the active websocket even if an earlier disconnect + # or fallback moved the session transport to stdio. + if (t := current_transport()) is not None: + session["transport"] = t with session["history_lock"]: if session.get("running"): return _err(rid, 4009, "session busy") + if truncate_user_ordinal is not None: + try: + ordinal = int(truncate_user_ordinal) + except (TypeError, ValueError): + return _err(rid, 4004, "truncate_before_user_ordinal must be an integer") + history = session.get("history", []) + user_indices = [i for i, m in enumerate(history) if m.get("role") == "user"] + if ordinal >= len(user_indices): + return _err(rid, 4018, "target user message is no longer in session history") + truncated = history[: user_indices[ordinal]] + session["history"] = truncated + session["history_version"] = int(session.get("history_version", 0)) + 1 + if (db := _get_db()) is not None: + try: + db.replace_messages(session["session_key"], truncated) + except Exception as exc: + print(f"[tui_gateway] prompt.submit: replace_messages failed: {exc}", file=sys.stderr) session["running"] = True session["last_active"] = time.time() _start_inflight_turn(session, text) @@ -3507,6 +3986,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: approval_token = set_current_session_key(session["session_key"]) session_tokens = _set_session_context(session["session_key"]) + cwd = _session_cwd(session) + _register_session_cwd(session) cols = session.get("cols", 80) streamer = make_stream_renderer(cols) prompt = text @@ -3526,8 +4007,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: ) ctx = preprocess_context_references( prompt, - cwd=os.environ.get("TERMINAL_CWD", os.getcwd()), - allowed_root=os.environ.get("TERMINAL_CWD", os.getcwd()), + cwd=cwd, + allowed_root=cwd, context_length=ctx_len, ) if ctx.blocked: @@ -3607,11 +4088,16 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: payload["rendered"] = r _emit("message.delta", sid, payload) - result = agent.run_conversation( - run_message, - conversation_history=list(history), - stream_callback=_stream, - ) + run_kwargs = { + "conversation_history": list(history), + "stream_callback": _stream, + } + try: + if "task_id" in inspect.signature(agent.run_conversation).parameters: + run_kwargs["task_id"] = session["session_key"] + except (TypeError, ValueError): + pass + result = agent.run_conversation(run_message, **run_kwargs) last_reasoning = None status_note = None @@ -3826,6 +4312,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: session["running"] = False session["last_active"] = time.time() _clear_inflight_turn(session) + _emit("session.info", sid, _session_info(agent, session)) # Chain a goal-continuation turn if the judge said so. We do # this AFTER the finally releases session["running"], so the @@ -3968,6 +4455,26 @@ def _(rid, params: dict) -> dict: return _err(rid, 5027, str(e)) +@method("image.detach") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + raw = str(params.get("path", "") or "").strip() + if not raw: + return _err(rid, 4015, "path required") + images = session.setdefault("attached_images", []) + before = len(images) + session["attached_images"] = [path for path in images if path != raw] + return _ok( + rid, + { + "detached": len(session["attached_images"]) != before, + "count": len(session["attached_images"]), + }, + ) + + @method("input.detect_drop") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) @@ -4061,6 +4568,108 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"task_id": task_id}) +@method("preview.restart") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + + url = str(params.get("url") or "").strip() + cwd = str(params.get("cwd") or "").strip() + context = str(params.get("context") or "").strip() + + if not url: + return _err(rid, 4012, "url required") + + task_id = f"preview_{uuid.uuid4().hex[:6]}" + parent = params.get("session_id", "") + parent_history = _preview_restart_history(session) + has_history = bool(parent_history) + prompt = "\n".join( + line + for line in [ + "The desktop preview pane cannot load a local server URL.", + "", + f"Preview URL: {url}", + f"Current working directory: {cwd or '(unknown)'}", + "", + f"Preview console:\n{context}" if context else "", + "" if context else "", + ( + "The conversation history above is from the user's main session — including the commands you (the assistant) previously ran to start servers, edit files, or check ports. Use it to figure out exactly which server should be running at this Preview URL. The user did not start a brand new task; recover what they had working." + if has_history + else None + ), + "Restart exactly the app intended for the Preview URL, not Hermes Desktop itself.", + "The Preview URL and port are the target. Preserve that target unless you conclude it is impossible.", + "If the prior conversation shows a specific command that bound this URL/port, prefer re-running THAT exact command (in the same cwd) over guessing a new one.", + "First inspect what process, if any, owns the Preview URL port. If a stale server exists, inspect its cwd and prefer that cwd over the Hermes/Desktop process cwd.", + "The Current working directory is only a hint. Do not assume it is the preview app root when the port owner or files indicate another root.", + "If the console shows a module-script MIME error for src/main.tsx or similar, a static server is serving source files. Do not restart python -m http.server or any dumb static server for that app.", + "For module-script MIME failures, inspect package.json/vite config in the candidate app root and start the real dev server/bundler (for example npm/pnpm/yarn dev) so module transforms happen.", + "Before declaring success, verify the Preview URL responds with the intended app, not Hermes Desktop. If it serves Hermes/Desktop UI or another unrelated app, stop that process and report failure.", + "Do not modify files. Do not ask the user unless blocked.", + "Prefer existing project scripts or commands when they are clear.", + "If a stale process owns the needed port, handle it safely.", + "Start long-running servers detached/in the background, then return immediately.", + "Do not run a foreground dev server command that blocks this background task.", + "Keep the final response short: what command/server was started, or why it could not be restarted.", + ] + if line + ) + + def run(): + session_tokens = _set_session_context(task_id) + try: + from run_agent import AIAgent + from tools.terminal_tool import register_task_env_overrides + + if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))): + register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))}) + + history_note = ( + f" (with {len(parent_history)} parent-session messages of context)" + if parent_history + else "" + ) + _emit( + "preview.restart.progress", + parent, + {"task_id": task_id, "text": f"Starting hidden restart agent{history_note}"}, + ) + result = AIAgent( + **_ephemeral_preview_agent_kwargs(session["agent"], task_id), + **_preview_restart_callbacks(parent, task_id), + ).run_conversation( + user_message=prompt, + task_id=task_id, + conversation_history=parent_history or None, + ) + text = ( + result.get("final_response", str(result)) + if isinstance(result, dict) + else str(result) + ) + _emit("preview.restart.complete", parent, {"task_id": task_id, "text": text}) + except Exception as e: + _emit( + "preview.restart.complete", + parent, + {"task_id": task_id, "text": f"error: {e}"}, + ) + finally: + try: + from tools.terminal_tool import clear_task_env_overrides + + clear_task_env_overrides(task_id) + except Exception: + pass + _clear_session_context(session_tokens) + + threading.Thread(target=run, daemon=True).start() + return _ok(rid, {"task_id": task_id}) + + # ── Methods: respond ───────────────────────────────────────────────── @@ -4215,7 +4824,7 @@ def _(rid, params: dict) -> dict: _emit( "session.info", params.get("session_id", ""), - _session_info(agent), + _session_info(agent, session), ) return _ok(rid, {"key": key, "value": nv}) @@ -4463,6 +5072,20 @@ def _(rid, params: dict) -> dict: _write_config_key("display.tui_status_indicator", raw) return _ok(rid, {"key": key, "value": raw}) + if key in {"cwd", "terminal.cwd", "workdir"}: + raw = str(value or "").strip() + if not raw: + return _err(rid, 4002, "cwd required") + cwd = os.path.abspath(os.path.expanduser(raw)) + if not os.path.isdir(cwd): + return _err(rid, 4002, f"working directory does not exist: {raw}") + _write_config_key("terminal.cwd", cwd) + os.environ["TERMINAL_CWD"] = cwd + return _ok( + rid, + {"key": "terminal.cwd", "value": cwd, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)}, + ) + if key in {"prompt", "personality", "skin"}: try: cfg = _load_cfg() @@ -4479,9 +5102,9 @@ def _(rid, params: dict) -> dict: pname, new_prompt = _validate_personality(str(value or ""), cfg) _write_config_key("display.personality", pname) _write_config_key("agent.system_prompt", new_prompt) - nv = str(value or "default") + nv = str(value or "none") history_reset, info = _apply_personality_to_session( - sid_key, session, new_prompt + sid_key, session, new_prompt, pname ) else: _write_config_key(f"display.{key}", value) @@ -4525,6 +5148,11 @@ def _(rid, params: dict) -> dict: from hermes_constants import display_hermes_home return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()}) + if key == "project": + cfg_terminal = _load_cfg().get("terminal") or {} + raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip() + cwd = _completion_cwd({"cwd": raw} if raw else {}) + return _ok(rid, {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)}) if key == "full": return _ok(rid, {"config": _load_cfg()}) if key == "prompt": @@ -4548,7 +5176,7 @@ def _(rid, params: dict) -> dict: if key == "personality": return _ok( rid, - {"value": (_load_cfg().get("display") or {}).get("personality", "default")}, + {"value": (_load_cfg().get("display") or {}).get("personality") or "none"}, ) if key == "reasoning": cfg = _load_cfg() @@ -4642,6 +5270,75 @@ def _(rid, params: dict) -> dict: return _err(rid, 5016, str(e)) +@method("setup.runtime_check") +def _(rid, params: dict) -> dict: + """Strict provider check: does the configured/default model actually resolve to a usable runtime? + + Unlike setup.status (which returns True if ANY provider auth state is + discoverable, including indirect fallbacks like ``gh auth token`` for + Copilot), this runs the same resolve_runtime_provider() call the agent + uses on session creation. It returns ok=False with the auth error message + when the user's configured model cannot actually be served, so UIs can + surface onboarding before the user submits a doomed prompt. + """ + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + from hermes_cli.auth import has_usable_secret + from hermes_cli.main import _has_any_provider_configured + + runtime = resolve_runtime_provider(requested=None) + provider_configured = bool(_has_any_provider_configured()) + provider = runtime.get("provider") or "provider" + source = str(runtime.get("source") or "") + if not provider_configured and provider == "bedrock" and source in { + "iam-role", + "aws-sdk-default-chain", + }: + return _ok( + rid, + { + "ok": False, + "provider": provider, + "model": runtime.get("model"), + "source": source, + "error": "No Hermes provider is configured.", + }, + ) + + api_key = runtime.get("api_key") + api_key_text = "" if callable(api_key) else str(api_key or "").strip() + credential_ok = ( + callable(api_key) + or api_key_text in {"aws-sdk", "no-key-required"} + or has_usable_secret(api_key_text) + or bool(runtime.get("command")) + ) + + if not credential_ok: + return _ok( + rid, + { + "ok": False, + "provider": provider, + "model": runtime.get("model"), + "source": runtime.get("source"), + "error": f"No usable credentials found for {provider}.", + }, + ) + + return _ok( + rid, + { + "ok": True, + "provider": runtime.get("provider"), + "model": runtime.get("model"), + "source": runtime.get("source"), + }, + ) + except Exception as e: + return _ok(rid, {"ok": False, "error": str(e)}) + + # ── Methods: tools & system ────────────────────────────────────────── @@ -4725,7 +5422,11 @@ def _(rid, params: dict) -> dict: "Failed to refresh cached agent tools after /reload-mcp: %s", _exc, ) - _emit("session.info", params.get("session_id", ""), _session_info(agent)) + _emit( + "session.info", + params.get("session_id", ""), + _session_info(agent, session), + ) # Honor `always=true` by persisting the opt-out to config. if bool(params.get("always", False)): @@ -5407,6 +6108,7 @@ def _(rid, params: dict) -> dict: items: list[dict] = [] try: + root = _completion_cwd(params) is_context = word.startswith("@") query = word[1:] if is_context else word @@ -5439,8 +6141,13 @@ def _(rid, params: dict) -> dict: # editors like Cursor / VS Code do for Cmd-P. Path-ish queries (with # `/`, `./`, `~/`, `/abs`) fall through to the directory-listing # path so explicit navigation intent is preserved. - if is_context and path_part and "/" not in path_part and prefix_tag != "folder": - root = os.getcwd() + if ( + is_context + and path_part + and len(path_part.strip()) >= 2 + and "/" not in path_part + and prefix_tag != "folder" + ): ranked: list[tuple[tuple[int, int], str, str]] = [] for rel in _list_repo_files(root): basename = os.path.basename(rel) @@ -5473,6 +6180,9 @@ def _(rid, params: dict) -> dict: search_dir = os.path.dirname(expanded) or "." match = os.path.basename(expanded) + search_dir = ( + search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) + ) if not os.path.isdir(search_dir): return _ok(rid, {"items": []}) @@ -5481,6 +6191,8 @@ def _(rid, params: dict) -> dict: for entry in sorted(os.listdir(search_dir)): if match and not entry.lower().startswith(match_lower): continue + if is_context and entry in _FUZZY_FALLBACK_EXCLUDES: + continue if is_context and not prefix_tag and entry.startswith("."): continue full = os.path.join(search_dir, entry) @@ -5490,7 +6202,7 @@ def _(rid, params: dict) -> dict: # which used to defeat the prefix and let `@folder:` list files. if prefix_tag and want_dir != is_dir: continue - rel = os.path.relpath(full) + rel = os.path.relpath(full, root).replace(os.sep, "/") suffix = "/" if is_dir else "" if is_context and prefix_tag: @@ -5721,6 +6433,7 @@ def _(rid, params: dict) -> dict: include_unconfigured=True, picker_hints=True, canonical_order=True, + pricing=True, max_models=50, ) return _ok(rid, payload) @@ -5884,24 +6597,24 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: result = _apply_model_switch(sid, session, arg) return result.get("warning", "") elif name == "personality" and arg and agent: - _, new_prompt = _validate_personality(arg, _load_cfg()) - _apply_personality_to_session(sid, session, new_prompt) + pname, new_prompt = _validate_personality(arg, _load_cfg()) + _apply_personality_to_session(sid, session, new_prompt, pname) elif name == "prompt" and agent: cfg = _load_cfg() - new_prompt = (cfg.get("agent") or {}).get("system_prompt", "") or "" + new_prompt = _prompt_text((cfg.get("agent") or {}).get("system_prompt", "")) agent.ephemeral_system_prompt = new_prompt or None agent._cached_system_prompt = None elif name == "compress" and agent: _compress_session_history(session, arg) _sync_session_key_after_compress(sid, session) - _emit("session.info", sid, _session_info(agent)) + _emit("session.info", sid, _session_info(agent, session)) elif name == "fast" and agent: mode = arg.lower() if mode in {"fast", "on"}: agent.service_tier = "priority" elif mode in {"normal", "off"}: agent.service_tier = None - _emit("session.info", sid, _session_info(agent)) + _emit("session.info", sid, _session_info(agent, session)) elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): agent.reload_mcp_tools() elif name == "stop": diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index a5879ef3a..5eff25e65 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -36,6 +36,7 @@ _log = logging.getLogger(__name__) # to flush a WS frame before we mark the transport dead. Protects handler # threads from a wedged socket. _WS_WRITE_TIMEOUT_S = 10.0 +_WS_LOG_PAYLOAD_PREVIEW = 240 # 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. @@ -61,9 +62,16 @@ class WSTransport: should use :meth:`write_async` from the loop thread. """ - def __init__(self, ws: Any, loop: asyncio.AbstractEventLoop) -> None: + def __init__( + self, + ws: Any, + loop: asyncio.AbstractEventLoop, + *, + peer: str = "unknown", + ) -> None: self._ws = ws self._loop = loop + self._peer = peer self._closed = False def write(self, obj: dict) -> bool: @@ -92,7 +100,7 @@ class WSTransport: return not self._closed except Exception as exc: self._closed = True - _log.debug("ws write failed: %s", exc) + _log.warning("ws write failed peer=%s error=%s", self._peer, exc) return False async def write_async(self, obj: dict) -> bool: @@ -107,43 +115,86 @@ class WSTransport: await self._ws.send_text(line) except Exception as exc: self._closed = True - _log.debug("ws send failed: %s", exc) + _log.warning("ws send failed peer=%s error=%s", self._peer, exc) def close(self) -> None: self._closed = True +def _ws_peer_label(ws: Any) -> str: + """Return ``host:port`` when available, else a stable placeholder.""" + client = getattr(ws, "client", None) + if client is None: + return "unknown" + host = getattr(client, "host", None) or "unknown" + port = getattr(client, "port", None) + return f"{host}:{port}" if port is not None else host + + 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()}, - }, - } - ) + peer = _ws_peer_label(ws) + transport: WSTransport | None = None + messages = 0 + parse_errors = 0 + dispatch_crashes = 0 + send_failures = 0 + disconnect_reason = "not_connected" try: + await ws.accept() + disconnect_reason = "connected" + _log.info("ws accepted peer=%s", peer) + + transport = WSTransport(ws, asyncio.get_running_loop(), peer=peer) + + ready_ok = await transport.write_async( + { + "jsonrpc": "2.0", + "method": "event", + "params": { + "type": "gateway.ready", + "payload": {"skin": server.resolve_skin()}, + }, + } + ) + if not ready_ok: + disconnect_reason = "ready_send_failed" + send_failures += 1 + _log.error("ws ready frame send failed peer=%s", peer) + return + while True: try: raw = await ws.receive_text() - except _WebSocketDisconnect: + except _WebSocketDisconnect as exc: + disconnect_reason = ( + "client_disconnect(" + f"code={getattr(exc, 'code', None)}," + f"reason={getattr(exc, 'reason', None)})" + ) + break + except Exception: + disconnect_reason = "receive_failed" + _log.exception("ws receive failed peer=%s", peer) break line = raw.strip() if not line: continue + messages += 1 try: req = json.loads(line) - except json.JSONDecodeError: + except json.JSONDecodeError as exc: + parse_errors += 1 + _log.warning( + "ws parse error peer=%s index=%d error=%s payload=%r", + peer, + messages, + exc, + line[:_WS_LOG_PAYLOAD_PREVIEW], + ) ok = await transport.write_async( { "jsonrpc": "2.0", @@ -152,6 +203,9 @@ async def handle_ws(ws: Any) -> None: } ) if not ok: + disconnect_reason = "send_failed_after_parse_error" + send_failures += 1 + _log.warning("ws parse-error reply send failed peer=%s", peer) break continue @@ -160,19 +214,69 @@ async def handle_ws(ws: Any) -> None: # 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) + req_id = req.get("id") if isinstance(req, dict) else None + req_method = req.get("method") if isinstance(req, dict) else None + try: + resp = await asyncio.to_thread(server.dispatch, req, transport) + except Exception: + dispatch_crashes += 1 + _log.exception( + "ws dispatch crash peer=%s id=%s method=%s", + peer, + req_id, + req_method, + ) + ok = await transport.write_async( + { + "jsonrpc": "2.0", + "error": {"code": -32603, "message": "internal error"}, + "id": req_id if req_id is not None else None, + } + ) + if not ok: + disconnect_reason = "send_failed_after_dispatch_crash" + send_failures += 1 + _log.warning( + "ws dispatch-crash reply send failed peer=%s id=%s method=%s", + peer, + req_id, + req_method, + ) + break + continue if resp is not None and not await transport.write_async(resp): + disconnect_reason = "send_failed_after_response" + send_failures += 1 + _log.warning( + "ws response send failed peer=%s id=%s method=%s", + peer, + req_id, + req_method, + ) 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 + detached_sessions = 0 + if transport is not None: + 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 + detached_sessions += 1 try: await ws.close() - except Exception: - pass + except Exception as exc: + _log.debug("ws close failed peer=%s error=%s", peer, exc) + _log.info( + "ws closed peer=%s reason=%s messages=%d parse_errors=%d " + "dispatch_crashes=%d send_failures=%d detached_sessions=%d", + peer, + disconnect_reason, + messages, + parse_errors, + dispatch_crashes, + send_failures, + detached_sessions, + ) diff --git a/ui-tui/src/lib/externalLink.ts b/ui-tui/src/lib/externalLink.ts index 812504836..67ac2b868 100644 --- a/ui-tui/src/lib/externalLink.ts +++ b/ui-tui/src/lib/externalLink.ts @@ -1,5 +1,4 @@ import { isIP } from 'node:net' - import { useEffect, useMemo, useState } from 'react' const titleCache = new Map<string, string>() diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index e06a9a731..d1b13ed55 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -10,7 +10,15 @@ Get Hermes Agent up and running in under two minutes with the one-line installer ## Quick Install -### One-Line Installer (Linux / macOS / WSL2) +### Desktop App (macOS + Windows) + +Prefer a native installer? + +- **Desktop downloads:** [GitHub Releases](https://github.com/NousResearch/hermes-agent/releases/latest) + +Desktop builds ship signed/notarized macOS artifacts and Windows installers with checksum files. + +### One-Line CLI Installer (Linux / macOS / WSL2) For a git-based install that tracks `main` and gives you the latest changes immediately: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 59543c894..b4ac2509b 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -64,6 +64,10 @@ PyPI releases track tagged versions (major/minor releases), not every commit on curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` +Prefer native installers for desktop use? + +- **Desktop downloads:** [GitHub Releases](https://github.com/NousResearch/hermes-agent/releases/latest) + :::tip Android / Termux If you're installing on a phone, see the dedicated [Termux guide](./termux.md) for the tested manual path, supported extras, and current Android-specific limitations. ::: diff --git a/website/docs/index.mdx b/website/docs/index.mdx index 2e071a268..9a93638e5 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -15,6 +15,7 @@ The self-improving AI agent built by [Nous Research](https://nousresearch.com). <div style={{display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap'}}> <Link to="/getting-started/installation" style={{display: 'inline-block', padding: '0.6rem 1.2rem', backgroundColor: '#FFD700', color: '#07070d', borderRadius: '8px', fontWeight: 600, textDecoration: 'none'}}>Get Started →</Link> + <a href="https://github.com/NousResearch/hermes-agent/releases/latest" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>Download Desktop</a> <a href="https://github.com/NousResearch/hermes-agent" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>View on GitHub</a> </div> diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 3c8d7bbc1..d90e5227c 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -116,7 +116,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| -| `/quit` | Exit the CLI (also: `/exit`). See note on `/q` under `/queue` above. Pass `--delete` (or `-d`) — e.g. `/exit --delete` — to also permanently remove the current session's SQLite history and on-disk transcripts before exiting. Useful for privacy-sensitive or one-off tasks. | +| `/quit` | Exit the CLI (also: `/exit`). | ### Dynamic CLI slash commands diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index 48a0e4812..60d9680cd 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -144,14 +144,22 @@ Traces Hermes turns, LLM calls, and tool invocations to [Langfuse](https://langf The plugin is fail-open: no SDK installed, no credentials, or a transient Langfuse error — all turn into a silent no-op in the hook. The agent loop is never impacted. -**Setup:** +**Setup (interactive — recommended):** + +```bash +hermes tools # → Langfuse Observability → Cloud or Self-Hosted +``` + +The wizard collects your keys, `pip install`s the `langfuse` SDK, and adds `observability/langfuse` to `plugins.enabled` for you. Restart Hermes and the next turn ships a trace. + +**Setup (manual):** ```bash pip install langfuse hermes plugins enable observability/langfuse ``` -Or check the box in the interactive `hermes plugins` UI. Then put the credentials in `~/.hermes/.env`: +Then put the credentials in `~/.hermes/.env`: ```bash HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-... diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 781fa5e8f..f543b5610 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -142,8 +142,6 @@ Within each source, Hermes also recognizes sub-category directories that route p User plugins at `~/.hermes/plugins/model-providers/<name>/` and `~/.hermes/plugins/memory/<name>/` override bundled plugins of the same name — last-writer-wins in `register_provider()` / `register_memory_provider()`. Drop a directory in, and it replaces the built-in without any repo edits. -Sub-category plugins surface in `hermes plugins list` and the interactive `hermes plugins` UI under their **path-derived key** — e.g. `observability/langfuse`, `image_gen/openai`, `platforms/teams`. That key (not the bare manifest `name:`) is the value you pass to `hermes plugins enable …` / `disable …` and the string to add under `plugins.enabled` in `config.yaml`. - ## Plugins are opt-in (with a few exceptions) **General plugins and user-installed backends are disabled by default** — discovery finds them (so they show up in `hermes plugins` and `/plugins`), but nothing with hooks or tools loads until you add the plugin's name to `plugins.enabled` in `~/.hermes/config.yaml`. This stops third-party code from running without your explicit consent. @@ -265,20 +263,17 @@ Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist ## Managing plugins ```bash -hermes plugins # unified interactive UI -hermes plugins list # table: enabled / disabled / not enabled -hermes plugins install user/repo # install from Git, then prompt Enable? [y/N] -hermes plugins install user/repo --enable # install AND enable (no prompt) -hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) -hermes plugins update my-plugin # pull latest -hermes plugins remove my-plugin # uninstall -hermes plugins enable my-plugin # add to allow-list (flat plugin) -hermes plugins enable observability/langfuse # add to allow-list (sub-category plugin) -hermes plugins disable my-plugin # remove from allow-list + add to disabled +hermes plugins # unified interactive UI +hermes plugins list # table: enabled / disabled / not enabled +hermes plugins install user/repo # install from Git, then prompt Enable? [y/N] +hermes plugins install user/repo --enable # install AND enable (no prompt) +hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) +hermes plugins update my-plugin # pull latest +hermes plugins remove my-plugin # uninstall +hermes plugins enable my-plugin # add to allow-list +hermes plugins disable my-plugin # remove from allow-list + add to disabled ``` -For plugins under a sub-category directory (e.g. `plugins/observability/langfuse/`, `plugins/image_gen/openai/`), use the full `<category>/<plugin>` key — that's exactly what `hermes plugins list` shows in the **Name** column. - ### Interactive UI Running `hermes plugins` with no arguments opens a composite interactive screen: @@ -291,7 +286,6 @@ Plugins → [✓] my-tool-plugin — Custom search tool [ ] webhook-notifier — Event hooks [ ] disk-cleanup — Auto-cleanup of ephemeral files [bundled] - [ ] observability/langfuse — Trace turns / LLM calls / tools to Langfuse [bundled] Provider Plugins Memory Provider ▸ honcho diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index c4ff60467..8c9cd4ba9 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -24,7 +24,7 @@ High-level categories: | **X Search** | `x_search` | Search X (Twitter) posts and threads via xAI's built-in `x_search` Responses tool — gated on xAI credentials (SuperGrok OAuth or `XAI_API_KEY`); off by default, opt in via `hermes tools` → 🐦 X (Twitter) Search. | | **Terminal & Files** | `terminal`, `process`, `read_file`, `patch` | Execute commands and manipulate files. | | **Browser** | `browser_navigate`, `browser_snapshot`, `browser_vision` | Interactive browser automation with text and vision support. | -| **Media** | `vision_analyze`, `image_generate`, `video_generate`, `video_analyze`, `text_to_speech` | Multimodal analysis and generation. `video_generate` and `video_analyze` are opt-in (add `video_gen` / `video` toolsets via `hermes tools` or `--toolsets`). | +| **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. | | **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. | | **Memory & recall** | `memory`, `session_search` | Persistent memory and session search. | | **Automation & delivery** | `cronjob`, `send_message` | Scheduled tasks with create/list/update/pause/resume/run/remove actions, plus outbound messaging delivery. | diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 6d6904d6c..265e1e5da 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -113,6 +113,11 @@ const config: Config = { label: 'Skills', position: 'left', }, + { + href: 'https://github.com/NousResearch/hermes-agent/releases/latest', + label: 'Download', + position: 'left', + }, { type: 'localeDropdown', position: 'right', @@ -157,6 +162,7 @@ const config: Config = { { title: 'More', items: [ + { label: 'Desktop Download', href: 'https://github.com/NousResearch/hermes-agent/releases/latest' }, { label: 'GitHub', href: 'https://github.com/NousResearch/hermes-agent' }, { label: 'Nous Research', href: 'https://nousresearch.com' }, ], diff --git a/website/src/components/UserStoriesCollage/styles.module.css b/website/src/components/UserStoriesCollage/styles.module.css index bc365e47b..42b9e6c7f 100644 --- a/website/src/components/UserStoriesCollage/styles.module.css +++ b/website/src/components/UserStoriesCollage/styles.module.css @@ -242,7 +242,11 @@ text-decoration: none; font-weight: 600; } -.footer a:hover { text-decoration: underline; } +.footer a:hover { + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 4px; +} .empty { padding: 3rem 1rem;