diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ae3c15d995..6c89396d0c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -154,3 +154,19 @@ updates: update-types: - minor - patch + + # Studio (the Node web client) — npm. + - package-ecosystem: npm + directory: "/studio" + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" + labels: + - dependencies + groups: + npm-minor-patch: + update-types: + - minor + - patch diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml new file mode 100644 index 0000000000..acc245cb99 --- /dev/null +++ b/.github/workflows/studio.yml @@ -0,0 +1,122 @@ +name: Studio + +on: + pull_request: + paths: + - "studio/**" + - "sdk/typescript/**" + - ".github/workflows/studio.yml" + push: + branches: [main] + paths: + - "studio/**" + - "sdk/typescript/**" + - ".github/workflows/studio.yml" + +permissions: + contents: read + +jobs: + checks: + name: Build, test, lint, typecheck, audit + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Studio depends on the TypeScript SDK as `file:../sdk/typescript`; npm + # links the checkout without building it, so the SDK is built first with + # its own toolchain (pnpm, pinned exactly as ci.yml's SDK job pins it). + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 11.25.0 + run_install: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: studio/.nvmrc + cache: npm + cache-dependency-path: studio/package-lock.json + + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 + + - name: Build the TypeScript SDK + run: | + task sdk:install + task sdk:build + + # No package in the tree needs install scripts (Next/sharp ship prebuilt + # binaries), so scripts stay off — supply-chain surface CI never runs. + - name: Install + working-directory: studio + run: npm ci --ignore-scripts + + - name: Lint, typecheck, dead-code + working-directory: studio + run: | + npm run lint + npm run typecheck + npm run knip + + - name: Unit tests + working-directory: studio + run: npx vitest run + + - name: Build + hermetic server-tier suite + working-directory: studio + run: npm run test:server + + - name: Audit + working-directory: studio + run: npm audit --audit-level=high + + - name: License headers + run: | + if git grep -l "SPDX-License-Identifier: Proprietary" -- studio/; then + echo "Proprietary SPDX headers are not allowed in studio/" >&2 + exit 1 + fi + + # Browser smoke over the real proxy tier against the fixture daemon. + # Separate job so a browser-infra flake never masks the checks above. + e2e: + name: Playwright (fixture daemon) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Studio depends on the TypeScript SDK as `file:../sdk/typescript`; npm + # links the checkout without building it, so the SDK is built first with + # its own toolchain (pnpm, pinned exactly as ci.yml's SDK job pins it). + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 11.25.0 + run_install: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: studio/.nvmrc + cache: npm + cache-dependency-path: studio/package-lock.json + + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 + + - name: Build the TypeScript SDK + run: | + task sdk:install + task sdk:build + + - name: Install + working-directory: studio + run: | + npm ci --ignore-scripts + npx playwright install --with-deps chromium + + - name: Build and test + working-directory: studio + run: npm run test:e2e diff --git a/.gitignore b/.gitignore index c75aa7ea65..89280faeae 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ coverage.* # Personal, local-only Claude Code instructions and running-state notes /CLAUDE.local.md /HANDOFF.md + +# Studio managed-mode local state (controller-pinned skills/memory dirs). +/.mecatl/ + +# Ad-hoc local attachment drops. +/attachments/ diff --git a/.matlatlignore b/.matlatlignore index 38ed9183e2..eca6128286 100644 --- a/.matlatlignore +++ b/.matlatlignore @@ -63,6 +63,12 @@ user-docs/ website/AGENTS.md website/CLAUDE.md +# Studio's managed-mode working state (the controller pins the project skills +# and memory dirs here) and ad-hoc local attachment drops — machine-local +# state, not documentation. CI checkouts never contain them; ignoring them +# keeps local `check`/`index` runs byte-identical to CI's. +/.mecatl/ +/attachments/ # API Extractor reports are review artifacts, not navigational documentation. # Exclude both the committed reports and their ignored comparison copies. sdk/typescript/etc/*.api.md diff --git a/AGENTS.md b/AGENTS.md index 0783ddeb1f..bd79e2d66d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ the opt-in `provider/*` submodules (ADR 0093), and the root module all move in l - `contracts/proto/mecatl/v1/` — gRPC contract (source of truth); `contracts/proto/mecatl/driver/v1/` — the driver protocol (SessionStoreService/MemoryStoreService stores; SkillSourceService/SoulSourceService/AgentSourceService/CommandSourceService content sources) a remote driver process implements; `contracts/gen/` is generated, **never hand-edit**. - `cmd/mecated/` — standalone server (composition root): flags, TLS/auth/rate-limit, HTTP + metrics listeners. `cmd/mecademo/` — the offline demo. `cmd/mecatequi/` — single-shot HEADLESS composition root (peer of mecademo over `app.Build`): one prompt → a git-diff patch + a JSON Summary + an optional JSONL log + an exit code. It is **FORGE-AGNOSTIC** — knows nothing about GitHub; the glue that turns an issue into a PR lives ONLY in `.github/` + shell, NEVER the binary or `engine/`, and keeps a **split-privilege token boundary** (the agent job holds NO GitHub write token; the publish job runs NO agent code, applies the patch as DATA). See `docs/adr/0028-mecatequi.md`. `cmd/mecak8s/` — storage-free k8s-native agent (ADR 0048), a thin peer of mecated that composes `app.Build` with k8s-native defaults (Redis store + k8s lease + drain gate); no PVC, no local state — state is a managed service (Redis + k8s API server). The four real-provider mains share credential/base-URL wiring via `internal/cliconfig`. - `cmd/mecatui/` — optional gRPC **client** TUI; by default hosts a `mecated` in-process over a UNIX socket. `ui`/`theme`/`client` import no `engine/...` or `internal/...` and no proto directly — they render from relayed proto `Event`s. See `docs/tui.md`. +- `studio/` — the web client: a Next.js **Node module**, never a Go module (not in `go.work`, the layering DAG, depguard, or api-compat). A CLIENT like mecatui, consuming the daemon EXCLUSIVELY through the TypeScript SDK (`@stacklok-oss/mecatl-sdk`, `sdk/typescript`, a `file:` dependency built before Studio installs) pointed at Studio's own same-origin `/api/mecatl` proxy, which holds the credential and injects auth ONLY (placement is server-owned, ADR 0291 — no workspace injection); controller calls (`/api/mecatl-control`) stay Studio-owned; daemon-only (an unreachable daemon renders offline, never demo data). Commands run via `task studio:*` (npm underneath). **A breaking HTTP/SSE wire change owes a Studio update in the same PR.** See ADR 0347/0348 + `studio/CLAUDE.md`. - `perf/` — the OFFLINE scenario perf harness (perf-tracking Phase 2, `task perf:scenarios`, NOT part of `task test`): `perf/kpi` (stdlib-ONLY KPI capture — `ScenarioResult`/`Capture`/`/proc` RSS sampler; never imports `engine/...` or `internal/...`) + `perf/scenarios` (external-test `testing.B` whole-loop benchmarks over `engine/...` + `engine/adapter/*`, never `internal/...`). The TUI scrollback render bench lives in `cmd/mecatui/ui/scrollback_bench_test.go` (perf/kpi imported in the `_test` file only). `perf/cmd/perfconvert` (Phase 3; stdlib + `perf/kpi` only) reshapes the scenario JSON into the two github-action-benchmark suites the CI gate consumes; `perf/cmd/benchtrend` (stdlib only, tested, FAIL-CLOSED) median-aggregates Go benchmark samples and compacts the commit-keyed dashboard history; and `perf/cmd/allocsgate` (stdlib only, tested, FAIL-CLOSED) is the deterministic allocs/op gate over `task bench` — the gate DECISION (benchstat is the local human A/B tool only, never the CI gate); the gate is `.github/workflows/perf.yml` (split: allocsgate over `task bench` + github-action-benchmark over the scenarios; PR fails-but-never-pushes, main pushes the `gh-pages` trend store). See `docs/adr/0019-perf-tracking.md`. ## The layering rule (the thing to get right) diff --git a/Taskfile.yml b/Taskfile.yml index fd738dd206..c1cd5eee37 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,6 +22,9 @@ includes: site: taskfile: website/Taskfile.yml dir: website + studio: + taskfile: studio/Taskfile.yml + dir: studio sdk: taskfile: sdk/typescript/Taskfile.yml dir: sdk/typescript diff --git a/cmd/mecated/helpmeta.go b/cmd/mecated/helpmeta.go index 869f63f839..08fa04dc0f 100644 --- a/cmd/mecated/helpmeta.go +++ b/cmd/mecated/helpmeta.go @@ -201,6 +201,7 @@ var flagMetaByFlag = map[string]flagMeta{ // ── Storage (both) ─────────────────────────────────────────────────── "store-dir": {group: groupStorage, common: true, acp: acpInclude}, + "local-storage-management": {group: groupStorage, common: false, acp: acpExclude}, "memory-dir": {group: groupStorage, common: true, acp: acpInclude}, "memory-consolidate-interval": {group: groupStorage, common: false, acp: acpInclude}, "child-retention": {group: groupStorage, common: false, acp: acpInclude}, diff --git a/cmd/mecated/main.go b/cmd/mecated/main.go index 966e5d9b74..ac8e642962 100644 --- a/cmd/mecated/main.go +++ b/cmd/mecated/main.go @@ -368,6 +368,11 @@ type config struct { // deployment where clients drive runs but never answer permission prompts — it // is what makes --subagent-ask-reviewer actually engage. headless bool + // localStorageManagement grants the private single-user storage + // management surface (health / clean-up) to unauthenticated-principal + // callers — the embedded-server grant, for a loopback daemon a trusted + // supervisor (Studio's controller) spawns. Refused with OIDC ownership. + localStorageManagement bool // Skills self-improvement loop (opt-in): when skillsDraftDir is non-empty the // writable SkillDraft tool is registered, writing model-authored candidate @@ -1322,6 +1327,7 @@ func appConfig(cfg config, sink port.EventSink, recorder port.ToolCallRecorder, // Headless is explicit deployment identity. The posture ladder raises // workspace trust only when this is false. Headless: cfg.headless, + LocalStorageManagement: cfg.localStorageManagement, Sink: sink, ToolCallRecorder: recorder, MetricsRoleScoper: roleScoper, @@ -1793,6 +1799,7 @@ func parseFlagsModeOut(mode commandMode, argv []string, out io.Writer) (*flag.Fl fs.IntVar(&cfg.subagentAskReviewerMaxDenies, "subagent-ask-reviewer-max-denies", agent.DefaultAskReviewMaxDenies, "circuit breaker for --subagent-ask-reviewer: after this many CONSECUTIVE non-allow reviewer outcomes (denies/errors/timeouts) in one run, further asks skip the reviewer and fall through to the plain auto-deny; an allow resets the count. <=0 uses the default (3)") fs.StringVar(&cfg.subagentAskReviewerPolicyFile, "subagent-ask-reviewer-policy", "", "path to a TRUSTED policy rubric file for --subagent-ask-reviewer; its CONTENT replaces the built-in read-only/verification rubric the reviewer applies. Empty keeps the built-in rubric. Read once at startup; an unreadable file FAILS STARTUP") fs.BoolVar(&cfg.subagentModelRouter, "subagent-model-router", false, "Semantic model router KILL-SWITCH (ADR 0042, superseding 0031's enable model): the router is ENABLED by configuring a `models.router:` category taxonomy in the OPERATOR-TIER user-global settings.yaml (the guardrails-parity enable model — configure = enable), NOT by this flag. Pass --subagent-model-router=false to force the router OFF despite a taxonomy (the kill-switch; also expressible as models.router.disabled: true in YAML). When ENABLED, a tiny one-turn classifier (on the `router` model slot) reads each plain Subagent delegation's task prompt + the operator taxonomy and picks the child's model BEFORE the child is minted (decide-once, same-provider; only for a plain delegation — no per-call model/agent, no fork/resume). FAIL-SOFT: any classifier failure, unknown category, or the per-run breaker (3 consecutive misses) inherits the default model") + fs.BoolVar(&cfg.localStorageManagement, "local-storage-management", false, "grant the storage management surface (GET /v1/storage/health, the clean-up plan/apply routes; advertised as capabilities.storage_health / storage_cleanup) to callers with no OIDC principal — the private single-user grant the embedded mecatui server has, for a LOOPBACK daemon a trusted supervisor spawns (Studio's controller passes it). Refused at startup together with --auth-oidc ownership enforcement; storage_management.principals in settings.yaml is the multi-tenant alternative. DEFAULT off") fs.BoolVar(&cfg.headless, "headless", false, "run NON-interactive: declare that clients drive sessions but never answer permission prompts (autonomous / CI deployments). A child subagent/member/branch permission ask is then NOT surfaced to the client (nobody would answer it — it would park until run-end) but resolved by the auto-deny path / the opt-in --subagent-ask-reviewer. DEFAULT off: a normal mecated serving an interactive client (mecatui, an IDE) surfaces asks for a human. Setting --subagent-ask-reviewer WITHOUT --headless has no effect (asks surface to the client instead) — a startup WARNING says so") fs.BoolVar(&cfg.planModeAutoApprove, "plan-mode-auto-approve", false, "OPT-IN autonomous plan approval (issue #206 Wave 6a): when a plan-mode run ends HEADLESS (no human to review), auto-approve the plan via ApprovePlan(ModeDefault) instead of leaving it parked. This is a deliberate autonomous-approval capability — an operator deployment decision, NEVER load-bearing for safety. It does NOT fire when interactive (a human can approve), NOT in non-plan modes, NOT for non-plan asks. DEFAULT off: a headless plan ask is auto-denied. Requires --headless to engage (an interactive deployment surfaces the plan to the human). A LOUD diagnostic (plan_mode_auto_approve: ON (NO HUMAN REVIEW)) is emitted at startup") fs.StringVar(&cfg.guardrailsModel, "guardrails-model", "", "GUARDRAILS (issue #27): model id or --model-alias of a tool-less checker that inspects OUTBOUND tool-call args (PreToolUse, data exfil) and INBOUND tool results (PostToolUse, prompt injection) and enforces a verdict per the operator-tier `guardrails:` rule list. Configuring a model here OR via a bound `guardrail` model slot (--model-slot guardrail=… / models.slots.guardrail) ENABLES guardrails (configure = enable, the router-parity model of ADR 0042; see ADR 0046) — empty + no slot disables them. A value that does not resolve to a usable model id FAILS STARTUP. A bound `guardrail` slot SUPERSEDES this flag's model when both are set (this flag then supplies only the enable gate). The RULE LIST + cost knobs live in the user-global settings.yaml `guardrails:` subtree (operator-tier ONLY — a project repo cannot configure or weaken a checker); --guardrails-model overrides the YAML model") diff --git a/docs/adr/0347-studio-atrium-module.md b/docs/adr/0347-studio-atrium-module.md new file mode 100644 index 0000000000..431822db9f --- /dev/null +++ b/docs/adr/0347-studio-atrium-module.md @@ -0,0 +1,125 @@ +# ADR 0347 — Studio: the Atrium workspace as mecatl's daemon-only web client + +- Status: Accepted +- Date: 2026-08-18 +- Scope: `studio/` — what the web client is, what it may talk to, how it deploys, + and what it deliberately does not do + +> History: this decision subsumes the unmerged ADR drafts from PR #548 ("Studio +> module", numbered 0225 there before that id was taken on main) and carries its +> security posture forward under the new UI. PR #548 is superseded by the change +> that lands this ADR. + +## Context + +Mecatl needed a web client. Two candidates existed side by side: the original +Studio module on the unmerged `feat/studio-module` branch — a single-page chat +with a hardened server tier (origin-pinned proxies, a managed-`mecated` +supervisor, a typed wire seam, behavior tests) but a one-file UI — and the +Atrium workspace prototype in `stacklok/enterprise-ui-prototypes`, a designed +five-surface product (Chats · Scheduled · Skills · Memory · Settings) that +already spoke mecated's SSE protocol, but grew inside a ToolHive console fork: +its own OIDC stack, a mock server, generated clients for services mecatl does +not have, and per-surface demo fallbacks that rendered fabricated content +whenever the daemon was away. + +Neither was shippable alone. The prototype had the product; the module had the +posture. + +## Decision + +**Studio is the Atrium workspace UI mounted on the original module's server +tier, in-repo at `studio/`, and it is daemon-only.** + +- **A Node module, never a Go module.** Studio is a CLIENT of the daemon like + `mecatui`: it is not in `go.work`, the layering DAG, depguard, or the + api-compat gate. It consumes only the public HTTP/SSE API, through its own + server-side route handlers — the browser never holds a daemon address or + credential. +- **Two pure deployment modes.** Managed: `scripts/local-controller.mjs` + supervises a `mecated` it spawns from `../bin/mecated` on a random loopback + port with a generated bearer token. External: `MECATL_BASE_URL` selects a + remote daemon; no controller runs and every local control surface answers 409 + as owned by the deployment. Nothing in between. +- **Daemon-only.** The prototype's mock server, demo fixtures, and per-hook + fallbacks are excluded at import. An unreachable daemon is a rendered state — + a shared runtime-status provider polls the daemon and controller, shows the + offline banner, and gates every surface's loads. Probe failure must never + produce fabricated content. (The managed controller may still run + `mecated --mock`: that is a real daemon with a mock LLM provider, which is + what offline development means here.) +- **One typed wire seam.** `src/lib/protocol/` is the only reader of raw daemon + JSON: structural decoders that throw on malformed frames, surface unknown + event kinds as visible notices, and encode the request/response asymmetry + (protojson requests, stdlib-JSON responses). Generated TypeScript bindings + from `contracts/proto` remain deferred; the seam plus its behavior tests is + the stopgap, and a breaking wire change owes a Studio update in the same PR. +- **The security posture is inherited, not re-derived.** Host/Origin/CSRF + pinning at the Next tier (`MECATL_STUDIO_PUBLIC_ORIGIN`); bearer injection + server-side only; controller mutations require the server-set + `x-mecatl-studio-request` header, loopback Host, and an allowlisted Origin; + bounded bodies; MCP gateway egress is HTTPS-only (loopback HTTP behind an + operator env opt-in) with the gateway URL user-entered but always validated; + the session workspace is resolved server-side (controller status or + `MECATL_WORKSPACE`) and injected into create bodies so the browser never + learns or chooses paths. +- **Deliberate non-features.** No provider-credential entry anywhere: mecated + reads keys from its auth file, and Studio's provider card only reports + status. Memory is read-only (the daemon has no write API, by design — a + hand-typed value would enter turn-0 context without injection scanning). + Skill and agent-definition authoring, learned-skill review, team runs, the + plan-approval flow, and live re-attach to running sessions (gRPC-only today) + are named follow-ups, not silent gaps. +- **Toolchain.** npm with a committed lockfile (security pins carried from the + prototype as npm `overrides`), Node 22 LTS via `.nvmrc`, Biome for + lint+format, vitest for unit/decoder tests, and a hermetic `node --test` + harness that boots the production build in external mode against a fake + recording daemon to prove the server tier's behavior. CI runs all of it plus + `npm audit --audit-level=high` and a license-header guard; installs run with + scripts disabled. + +## Consequences + +One product instead of two halves: the designed workspace, on the hardened +tier, with the daemon as the single source of truth. + +The costs, stated plainly: + +- A fresh checkout without `bin/mecated` shows an offline screen, not a demo. + The screen names the fix (`task build`, then `task studio:dev`); losing the + zero-setup demo is the price of never rendering fabricated state. +- The deferred surfaces are real feature regressions against the prototype's + dormant code (authoring flows existed there, unmounted) and stay out until + they can land controller-mediated with the same posture. +- Vendoring a designed UI brings a large dependency tree (~40 runtime + packages) into the repo's audit surface; dependabot and the audit gate own + that from here. +- The same-PR rule now binds a much larger client: a daemon wire change costs + a Studio change in the same PR, every time. + +## Status / amendments + +- **2026-09-15 — the wire seam is the TypeScript SDK.** The "One typed wire + seam" decision above described `src/lib/protocol/` — hand-written structural + decoders, with generated TypeScript bindings deferred. That stopgap is + superseded: Studio now consumes the daemon exclusively through the + TypeScript SDK (`@stacklok-oss/mecatl-sdk`, source `sdk/typescript`, + installed into `studio/` as a `file:../sdk/typescript` dependency that CI + and `task studio:install` build first). The SDK carries the generated + `contracts/proto` bindings, the request/response asymmetry, the SSE run and + durable-watch decoding, and the unknown-event-kind forward compatibility. + The SDK's HTTP transport is pointed at Studio's same-origin `/api/mecatl` + proxy, which keeps the inherited security posture unchanged: it pins + Host/Origin, holds and injects the credential, and forwards every daemon + request otherwise verbatim — the former server-side workspace injection is + gone because session placement is server-owned (ADR 0291). Controller calls + (`/api/mecatl-control/*`) remain Studio-owned and are not part of the SDK. + The same-PR rule stands, re-targeted: a breaking wire change lands in the + SDK first and Studio moves with it. + +## See also + +- [ADR 0348](./0348-studio-server-backed-chats.md) — the chat list is the + daemon's session store +- `docs/architecture.md` — the Studio client section +- `user-docs/building/what-you-get/studio.md` — what operating it looks like diff --git a/docs/adr/0348-studio-server-backed-chats.md b/docs/adr/0348-studio-server-backed-chats.md new file mode 100644 index 0000000000..d247696a77 --- /dev/null +++ b/docs/adr/0348-studio-server-backed-chats.md @@ -0,0 +1,96 @@ +# ADR 0348 — Studio's chat list is the daemon's session store + +- Status: Accepted +- Date: 2026-08-18 +- Scope: `studio/` — where a chat's existence, title, and transcript live, and which + client actions are server calls + +> History: re-lands the unmerged draft from PR #615 (numbered 0227 there before +> that id was taken on main), adapted to the Atrium workspace UI. + +## Context + +The prototype Studio descended from kept its chat list in browser state — first +a `localStorage` key, then mock fixtures mirrored in module scope. `mecated` +persisted the same sessions the whole time: the daemon already exposed +`GET /v1/sessions`, `GET /v1/sessions/{id}/transcript`, +`POST /v1/sessions/{id}/rename`, and `POST /v1/sessions/{id}/delete` — and the +client called none of them. + +Every consequence followed from that one gap: + +- A second browser, or a second machine, saw nothing. The daemon held the work; + the client that opened it held the only record of what the work was called. +- Renaming a chat renamed a local label that no other client would ever see. +- Deleting a chat dropped the client's copy and left the session in the store + forever — "delete" quietly meant "hide". +- A reload during a run orphaned it. `mecated` keeps running after the page + that started it goes away, but the client had no way back to the result: the + run streams off the `POST /prompt` response body, and that body is gone. +- The client also minted its own session ids and mapped them lazily onto daemon + sessions, so the daemon's record and the sidebar disagreed about what even + existed. + +The persistence was never missing. It was unused. + +## Decision + +The daemon's session store is the record of which chats exist, what each is +called, and what was said in each. Studio reads and writes that record, and +**the sidebar id IS the daemon session id** — there is no client-side session +mapping. + +- **The chat list is the session inventory.** Studio walks `GET /v1/sessions` + when the daemon becomes reachable and on a slow poll, and merges the result + into its list. A row is removed only when a COMPLETE walk proves it gone — + a partial walk updates what it saw and never deletes. Rows whose one + not-a-chat reason is `inspect_only_kind` (subagents, team members, scheduled + fires) are filtered by the decoder, not by id-prefix guessing. +- **Rename is `POST …/rename`.** The local update is optimistic and rolls back + if the daemon refuses. Studio adopts the title the daemon actually stored, + which is clamped, rather than the one it asked for. +- **Delete is `POST …/delete`.** A refusal keeps the row: the chat still + exists, and hiding it locally is the exact failure this ADR is about. Only a + 404 — the daemon saying it has no such session — removes a row without a + witnessed walk. +- **Opening a chat reads `GET …/transcript`.** The authoritative message-level + snapshot, which also covers scheduler-tick fires whose conversation never + reached the durable event log, and works identically in external mode. A + transcript the daemon cannot prove whole says so in the UI. +- **Eligibility is the daemon's, not Studio's.** Each inventory row carries + `capabilities` plus a closed machine-readable reason per disabled action. + Studio renders the reason; it does not re-derive who may rename or delete + what, so tightening the rule server-side needs no client change. +- **A new chat is a draft.** No daemon session is created until the first + prompt is sent; the mint happens then, and the route adopts the daemon's id. + Empty sessions never accumulate in the store from idle "new chat" clicks. + +## Consequences + +A chat renamed or deleted on one machine is renamed or deleted on every +machine, and survives clearing the browser. Sessions can finally be removed +from the store through the UI. A reload during a run no longer loses it: the +inventory row shows the session still running on the daemon, and the +transcript is read back when it ends. + +The costs, stated plainly: + +- **The list reorders on rename.** A rename advances the daemon's stored + mtime, and that mtime is the only ordering every client can agree on. +- **A rehydrated transcript is not identical to the live one.** The transcript + endpoint returns the model's conversation, which includes the harness's own + synthetic continuations as user-role messages with no provenance to + distinguish them. Fixing this needs a provenance field on the daemon's + `ConversationMessage`, not string-sniffing in the client. +- **Reattaching to a LIVE run is still not possible.** Studio can see a run in + flight and read its transcript once it ends, but it cannot resume the + stream: `GET /v1/sessions/{id}/events` is a finite replay, and the live + per-session subscription is reachable only over gRPC `StreamSessionLive`. + Closing that gap is a daemon change, not a client one. +- **Polling.** The list reconciles on an interval rather than a push, so a + change made elsewhere appears within seconds, not instantly. + +## See also + +- [ADR 0347](./0347-studio-atrium-module.md) — the Studio module and its + daemon-only posture diff --git a/docs/adr/README.md b/docs/adr/README.md index 5811633890..a3b1595f5b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -267,6 +267,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE - [0087 — Staged mecatui transport migration](./0087-mecatui-staged-transport-migration.md) *(superseded by 0089)* - [0088 — Explicit daemon.yaml (listener topology config)](./0088-daemon-config-file.md) - [0222 — mecatui: ctrl+t routes by ask type; full-screen ask-args view](./0222-mecatui-ask-args-view.md) +- [0347 — Studio: the Atrium workspace as mecatl's daemon-only web client](./0347-studio-atrium-module.md) +- [0348 — Studio's chat list is the daemon's session store](./0348-studio-server-backed-chats.md) - [0247 — mecatui generated status lines](./0247-mecatui-status-line.md) *(superseded by 0289)* - [0289 — Hardened status-command output and environment extension](./0289-hardened-status-command-boundary.md) - [0344 — Mecatui-owned terminal titles](./0344-mecatui-terminal-title-controller.md) *(proposed)* diff --git a/docs/architecture.md b/docs/architecture.md index 5cbb9f3c46..fe1e161b80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -999,6 +999,27 @@ HTTPS, origin, CA, hostname, and redirect safeguards. Kind remote login is avail the public CA, but is a live qualification path, not ordinary offline-test coverage. See [ADR 0275](adr/0275-bounded-scoped-https-keepalive-oidc.md), [ADR 0277](adr/0277-remote-mecatui-oidc.md), [ADR 0287](adr/0287-target-aware-mecatui-tls.md), and [ADR 0274](adr/0274-remote-mecatui-logout-budget.md). +**Studio — the web client (`studio/`).** An optional Next.js *client* of the public +HTTP/SSE API, in-repo as a Node module (never a Go module — not in `go.work`, the +layering DAG, depguard, or the api-compat gate). The browser talks only to Studio's +own server-side proxy routes (`/api/mecatl/*`, `/api/mecatl-control/*`), which pin +Host/Origin, inject the bearer token server-side, and allowlist headers in both +directions; the daemon proxy forwards bodies and queries verbatim (session +placement is server-owned, ADR 0291 — nothing injects a workspace). Two pure deployment modes: managed (a local +controller supervises a `mecated` spawned from `bin/mecated` on a random loopback +port with a generated bearer) or external (`MECATL_BASE_URL`; every local control +surface answers 409 as deployment-owned). Studio is daemon-only — an unreachable +daemon renders as an offline state, never demo content — and consumes the daemon +exclusively through the TypeScript SDK (`@stacklok-oss/mecatl-sdk`, source +`sdk/typescript`, installed as a `file:` dependency and built before Studio +installs) with the SDK's HTTP transport pointed at the same-origin `/api/mecatl` +proxy; the SDK owns wire decoding (generated proto bindings, unknown event kinds +surfaced as typed unknowns), and only the controller calls remain Studio-owned. +Live re-attach to a running session rides the SDK's durable watch +(`GET /v1/sessions/{id}/watch`, ADR 0250). A breaking wire change is absorbed by +the SDK first; Studio moves with it in the same PR. See ADR 0347 (and its +amendment) and ADR 0348. + **mecatequi — the single-shot headless runner (`cmd/mecatequi`).** A fourth composition root and a *peer of `mecademo`* over the same `app.Build`: it runs **one** prompt against an in-process `server.Service`, drives it to a terminal state, and emits three diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index 681d8d3eb7..e1478524cc 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -48,6 +48,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do | mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ Pod-only plaintext drain listener (8082; normal Service/gateway HTTP cannot drain) (ADR 0290) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ isolated Redis follow pools and fail-fast process-local follower admission, default 32/32 (ADR 0330) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0290](../adr/0290-mecak8s-drain-listener.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0330](../adr/0330-isolated-redis-follow-capacity.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) | | ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) | | Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) | +| Studio (web client) | ✅ Atrium workspace shipped daemon-only through the TypeScript SDK (`@stacklok-oss/mecatl-sdk`): five surfaces (Chats · Scheduled · Skills · Memory · Settings) on the hardened proxy tier, managed/external modes, hermetic server-tier + protocol + e2e suites · ✅ mecatui parity program (September 2026, PR #1607): 100 packages across 14 gated waves closed the audited partial and missing rows — plan-mode approval with auto-proceed, approval queue and verdict keys, live re-attach over the durable watch (ADR 0250), delegation panel with per-child cancel, MCP inventory / resources / prompts / per-tool authorization, learned skills and learning proposals, skill authoring (controller-mediated), soul viewer, posture / trust / shell-less controls, retention and diagnostics · ⛔ deferred: OIDC login for an operator-defined custom provider (daemon change) · saved remote-target registry and connect picker (deployment config) · project-scoped learning proposals (the daemon keys the partition by filesystem path) · external status-line executable · Studio container image · `displayServerEndpoint` on `/v1/info` (daemon + SDK) · agent-definition authoring | [0347](../adr/0347-studio-atrium-module.md) · [0348](../adr/0348-studio-server-backed-chats.md) | [overview](../architecture.md) | | _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — | ## Security diff --git a/internal/adapter/server/sdk_typescript_http_routes_test.go b/internal/adapter/server/sdk_typescript_http_routes_test.go index 2b3160edb5..4c42297311 100644 --- a/internal/adapter/server/sdk_typescript_http_routes_test.go +++ b/internal/adapter/server/sdk_typescript_http_routes_test.go @@ -278,7 +278,13 @@ func assertSDKHTTPBodyCodec(t *testing.T, key string, review sdkHTTPReview, fact } } wantBody := "none" - if len(remaining) != 0 { + _, correlationOnly := facts.calls["controlRequestBodyEmpty"] + switch { + case correlationOnly: + // The handler enforces the correlation-only shape: any request body is + // rejected with HTTP 400, so the request message's remaining fields are + // gRPC frame fields the HTTP route never carries. + case len(remaining) != 0: wantBody = "json" if facts.optionalBody { wantBody = "optional-json" diff --git a/sdk/typescript/CHANGELOG.md b/sdk/typescript/CHANGELOG.md index 51389db83a..384de68c43 100644 --- a/sdk/typescript/CHANGELOG.md +++ b/sdk/typescript/CHANGELOG.md @@ -4,6 +4,39 @@ Notable changes to `@stacklok-oss/mecatl-sdk` are recorded here. For installation and API entry points, see the [TypeScript SDK README](./README.md). +## Unreleased + +- Added `session.mcpAuthorization(id)` (`presentation()`, `recheck()`, `cancel()`) + for the per-tool MCP authorization flow, `session.mcpConnectors()` for the + broker-local connector catalogue, `session.workspaceEnrollment` + (`connect()`, `retry()`, `cancel()`) for pre-prompt workspace-services + enrollment, and `session.cancelChild(childId)` to stop one running subagent, + branch, or team member — every daemon route is now reachable through a typed + method. +- The HTTP transport now wraps the daemon's bare SSE events for every stream + whose response is a single-field `event` envelope, not only `Converse`, so + the authorization control streams decode. +- Added `session.controls(runId)`: strict, run-id-addressed HTTP controls + (`resolveAsk`, `cancel`, `steer`, `cancelSteer`) that need no event stream, + so a client can act on a run it re-attached to or observes through a durable + watch. `steer` returns the server's `accepted` / `appended` / `too_late` + outcome and accepts a client-minted `messageId` and media parts; a control + that outlives its run surfaces as `ServerError` `stale_run_control`. +- The HTTP transport now decodes `google.protobuf.Timestamp` and + `google.protobuf.Duration` fields the daemon marshals with stdlib + `encoding/json` (`{"seconds", "nanos"}` objects) — learning proposals, + learned skills, dream plans, schedule specs and fires, and session + snapshots no longer fail with `ProtocolError` against a real daemon. +- `session.mcpAuthorization(id).recheck()` and `.cancel()` now post with no + request body over HTTP; the daemon rejects any body on those controls, so + the `{}` the empty request message encoded to failed with HTTP 400. +- Added `AttachOptions.onReconnect`, an observer called before each watch + reconnect attempt with the attempt number and the backoff delay, so a client + can show "reconnecting…" state; a throwing listener never breaks the loop. +- Exported `PLAN_APPROVAL_TOOL` and `PLAN_APPROVED_PROCEED_TEXT` so clients + that render plan asks or continuation prompts share the SDK's constants + instead of mirroring the daemon's strings. + ## [0.2.0](https://www.npmjs.com/package/%40stacklok-oss%2Fmecatl-sdk/v/0.2.0) - fix(sdk): align Biome schema version (#1426) ([`ebb14c7`](https://github.com/stacklok/mecatl/commit/ebb14c78823946909a5ec2957d42b485bcef1e96)) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 6dd269c250..f77b41c6a7 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -58,6 +58,34 @@ fact, then raises the same error if iteration continues. Both leave the cursor a last envelope before the gap. `CursorExpiredError` also ends the attachment and requires the caller to choose an explicit restart from the beginning or a transcript reload. +## Run controls by run id + +A `Run` returned by `session.run()` carries its own `approve`, `cancel`, and `steer`. +When the client did not start the run — it re-attached after a reload, or it follows +the run through `session.activity()` — `session.controls(runId)` gives the same +controls addressed by run id, over HTTP only. Every control is strict: it names the +run through `expected_run_id`, so one that outlives its run is refused as +`stale_run_control` instead of acting on the session's next run. `steer` returns the +server's `accepted`, `appended`, or `too_late` outcome (the caller keeps the text on +`too_late`) and takes a client-minted `messageId` that the run's later `steer` event +echoes as the drained bundle's watermark; `cancelSteer` retracts the pending bundle. +`client.server.compatibility()` reports whether the server advertises the `http_steer` feature +these two controls require. + +## MCP authorization, connectors, and workspace enrollment + +When a tool call parks on `authorization.required`, `session.mcpAuthorization(id)` +returns the controls for that one authorization: `presentation()` reads the live +browser URL, `recheck()` asks the daemon to re-inspect it and streams the outcome +(and the resumed run when it succeeds), and `cancel()` abandons it with the same +stream shape. `session.mcpConnectors()` inspects the session's broker-local MCP +connector catalogue without probing upstreams, and `session.workspaceEnrollment` +(`connect()`, `retry(id)`, `cancel(id)`) drives the pre-prompt workspace-services +enrollment. `session.cancelChild(childId)` stops one running subagent, parallel +branch, or team member without cancelling the whole run. All are HTTP controls; +gate the first three on the `mcp_connector_status` and `workspace_enrollment` +server capabilities. + ## Node and Bun local daemon Node/Bun callers can import `spawn` from `@stacklok-oss/mecatl-sdk/node`. It resolves an existing diff --git a/sdk/typescript/etc/mecatl-sdk-deno.api.md b/sdk/typescript/etc/mecatl-sdk-deno.api.md index 44ac1835aa..e0e747197e 100644 --- a/sdk/typescript/etc/mecatl-sdk-deno.api.md +++ b/sdk/typescript/etc/mecatl-sdk-deno.api.md @@ -85,6 +85,10 @@ export interface AttachedRun extends SessionActivity { export interface AttachOptions { from?: "now" | "start" | SdkCursor; includeLogOnly?: boolean; + onReconnect?: (info: { + readonly attempt: number; + readonly delayMs: number; + }) => void; signal?: AbortSignal; } @@ -612,6 +616,43 @@ export const MAX_PROMPT_MEDIA_BYTES: number; // @public export const MAX_PROMPT_MEDIA_PARTS = 16; +// @public +export interface McpAuthorization { + // (undocumented) + readonly authorizationId: string; + cancel(options?: RequestOptions): McpAuthorizationStream; + presentation(options?: RequestOptions): Promise; + recheck(options?: RequestOptions): McpAuthorizationStream; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationOutcome { + readonly result?: RunResult; +} + +// @public +export interface McpAuthorizationPresentation { + readonly url: string; +} + +// @public +export interface McpAuthorizationStream extends AsyncIterable { + close(): Promise; + result(): Promise; +} + +// @public +export interface McpConnectorStatus { + // (undocumented) + readonly catalogueState: string; + // (undocumented) + readonly name: string; + // (undocumented) + readonly toolCount: number; +} + // @public export interface McpInventory { // Warning: (ae-forgotten-export) The symbol "GetMcpPromptRequest" needs to be exported by the entry point deno.d.ts @@ -796,6 +837,12 @@ export type PermissionAskResponder = (ask: PermissionAskEventPayload, signal: Ab // @public export type PermissionVerdict = "allow_once" | "allow_always" | "deny"; +// @public +export const PLAN_APPROVAL_TOOL = "PresentPlan"; + +// @public +export const PLAN_APPROVED_PROCEED_TEXT = "Plan approved by operator. Proceed with execution."; + // @public export class PlanApprovalRequiredError extends InvalidStateError { constructor(); @@ -922,6 +969,18 @@ export interface Run extends AsyncIterable { steer(text: string): Promise; } +// @public +export interface RunControls { + cancel(options?: RequestOptions): Promise; + cancelSteer(options?: SteerControlOptions): Promise; + resolveAsk(askId: string, verdict: PermissionVerdict, options?: RequestOptions): Promise; + // (undocumented) + readonly runId: string; + // (undocumented) + readonly sessionId: string; + steer(prompt: PromptInput, options?: SteerControlOptions): Promise; +} + // @public export interface RunOptions { onPermissionAsk?: PermissionAskResponder; @@ -1125,12 +1184,16 @@ export type ServerPosture = (typeof ServerPosture)[keyof typeof ServerPosture]; export interface Session { activity(options?: AttachOptions): Promise; attach(runId?: string, options?: AttachOptions): Promise; + cancelChild(childId: string, options?: RequestOptions): Promise; clear(options?: ClearSessionOptions, requestOptions?: RequestOptions): Promise; close(options?: RequestOptions): Promise; compact(options?: RequestOptions): Promise; + controls(runId: string): RunControls; delete(options?: RequestOptions): Promise; // (undocumented) readonly id: string; + mcpAuthorization(authorizationId: string): McpAuthorization; + mcpConnectors(options?: RequestOptions): Promise; rename(title: string, options?: RequestOptions): Promise; resolvePlan(verdict?: PlanApprovalVerdict): PlanResolution; retry(options?: RunOptions, requestOptions?: RequestOptions): Promise; @@ -1138,6 +1201,7 @@ export interface Session { setMode(mode: SessionMode, options?: RequestOptions): Promise; snapshot(options?: RequestOptions): Promise; transcript(options?: RequestOptions): Promise; + readonly workspaceEnrollment: WorkspaceEnrollmentControls; } // @public @@ -1179,6 +1243,20 @@ export interface SessionLimits { maxTurns?: number; } +// @public +export interface SessionMcpConnectors { + // (undocumented) + readonly availability: string; + // (undocumented) + readonly connectors: readonly McpConnectorStatus[]; + // (undocumented) + readonly enrollmentState: string; + // (undocumented) + readonly totalConnectors: number; + // (undocumented) + readonly truncated: boolean; +} + // @public export interface SessionMcpServer { command?: string; @@ -1406,6 +1484,31 @@ export interface SpawnOptions extends ClientDiagnosticsOptions { tempDirectory?: string; } +// @public +export interface SteerAck { + readonly messageId: string; + // (undocumented) + readonly outcome: SteerOutcome; + readonly promoted: boolean; + readonly runId: string; +} + +// @public +export interface SteerCancelAck { + // (undocumented) + readonly messageId: string; + // (undocumented) + readonly outcome: SteerCancelOutcome; +} + +// @public +export type SteerCancelOutcome = "retracted" | "none_pending"; + +// @public +export interface SteerControlOptions extends RequestOptions { + messageId?: string; +} + // @public export interface SteerEventPayload { // (undocumented) @@ -1416,6 +1519,9 @@ export interface SteerEventPayload { readonly text: string; } +// @public +export type SteerOutcome = "accepted" | "appended" | "too_late"; + // @public export interface SteerOutcomeEventPayload { // (undocumented) @@ -1829,6 +1935,24 @@ export interface WatchGapEnvelope { // @public export function withSessionAffinity(sessionId: string, options?: CallOptions): CallOptions; +// @public +export interface WorkspaceEnrollmentControls { + cancel(enrollmentId: string, options?: RequestOptions): Promise; + connect(options?: RequestOptions): Promise; + retry(enrollmentId: string, options?: RequestOptions): Promise; +} + +// @public +export interface WorkspaceEnrollmentState { + // (undocumented) + readonly enrollmentId: string; + readonly presentationUrl: string; + // (undocumented) + readonly requiredServices: number; + // (undocumented) + readonly status: string; +} + // @public export interface Worktrees { // Warning: (ae-forgotten-export) The symbol "ListWorktreesRequest" needs to be exported by the entry point deno.d.ts diff --git a/sdk/typescript/etc/mecatl-sdk-node.api.md b/sdk/typescript/etc/mecatl-sdk-node.api.md index 8f3f00c7fa..9b8cee7cf1 100644 --- a/sdk/typescript/etc/mecatl-sdk-node.api.md +++ b/sdk/typescript/etc/mecatl-sdk-node.api.md @@ -85,6 +85,10 @@ export interface AttachedRun extends SessionActivity { export interface AttachOptions { from?: "now" | "start" | SdkCursor; includeLogOnly?: boolean; + onReconnect?: (info: { + readonly attempt: number; + readonly delayMs: number; + }) => void; signal?: AbortSignal; } @@ -633,6 +637,43 @@ export const MAX_PROMPT_MEDIA_BYTES: number; // @public export const MAX_PROMPT_MEDIA_PARTS = 16; +// @public +export interface McpAuthorization { + // (undocumented) + readonly authorizationId: string; + cancel(options?: RequestOptions): McpAuthorizationStream; + presentation(options?: RequestOptions): Promise; + recheck(options?: RequestOptions): McpAuthorizationStream; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationOutcome { + readonly result?: RunResult; +} + +// @public +export interface McpAuthorizationPresentation { + readonly url: string; +} + +// @public +export interface McpAuthorizationStream extends AsyncIterable { + close(): Promise; + result(): Promise; +} + +// @public +export interface McpConnectorStatus { + // (undocumented) + readonly catalogueState: string; + // (undocumented) + readonly name: string; + // (undocumented) + readonly toolCount: number; +} + // @public export interface McpInventory { // Warning: (ae-forgotten-export) The symbol "GetMcpPromptRequest" needs to be exported by the entry point node.d.ts @@ -825,6 +866,12 @@ export type PermissionAskResponder = (ask: PermissionAskEventPayload, signal: Ab // @public export type PermissionVerdict = "allow_once" | "allow_always" | "deny"; +// @public +export const PLAN_APPROVAL_TOOL = "PresentPlan"; + +// @public +export const PLAN_APPROVED_PROCEED_TEXT = "Plan approved by operator. Proceed with execution."; + // @public export class PlanApprovalRequiredError extends InvalidStateError { constructor(); @@ -951,6 +998,18 @@ export interface Run extends AsyncIterable { steer(text: string): Promise; } +// @public +export interface RunControls { + cancel(options?: RequestOptions): Promise; + cancelSteer(options?: SteerControlOptions): Promise; + resolveAsk(askId: string, verdict: PermissionVerdict, options?: RequestOptions): Promise; + // (undocumented) + readonly runId: string; + // (undocumented) + readonly sessionId: string; + steer(prompt: PromptInput, options?: SteerControlOptions): Promise; +} + // @public export interface RunOptions { onPermissionAsk?: PermissionAskResponder; @@ -1154,12 +1213,16 @@ export type ServerPosture = (typeof ServerPosture)[keyof typeof ServerPosture]; export interface Session { activity(options?: AttachOptions): Promise; attach(runId?: string, options?: AttachOptions): Promise; + cancelChild(childId: string, options?: RequestOptions): Promise; clear(options?: ClearSessionOptions, requestOptions?: RequestOptions): Promise; close(options?: RequestOptions): Promise; compact(options?: RequestOptions): Promise; + controls(runId: string): RunControls; delete(options?: RequestOptions): Promise; // (undocumented) readonly id: string; + mcpAuthorization(authorizationId: string): McpAuthorization; + mcpConnectors(options?: RequestOptions): Promise; rename(title: string, options?: RequestOptions): Promise; resolvePlan(verdict?: PlanApprovalVerdict): PlanResolution; retry(options?: RunOptions, requestOptions?: RequestOptions): Promise; @@ -1167,6 +1230,7 @@ export interface Session { setMode(mode: SessionMode, options?: RequestOptions): Promise; snapshot(options?: RequestOptions): Promise; transcript(options?: RequestOptions): Promise; + readonly workspaceEnrollment: WorkspaceEnrollmentControls; } // @public @@ -1208,6 +1272,20 @@ export interface SessionLimits { maxTurns?: number; } +// @public +export interface SessionMcpConnectors { + // (undocumented) + readonly availability: string; + // (undocumented) + readonly connectors: readonly McpConnectorStatus[]; + // (undocumented) + readonly enrollmentState: string; + // (undocumented) + readonly totalConnectors: number; + // (undocumented) + readonly truncated: boolean; +} + // @public export interface SessionMcpServer { command?: string; @@ -1437,6 +1515,31 @@ export interface SpawnOptions extends ClientDiagnosticsOptions { toolServerName?: string; } +// @public +export interface SteerAck { + readonly messageId: string; + // (undocumented) + readonly outcome: SteerOutcome; + readonly promoted: boolean; + readonly runId: string; +} + +// @public +export interface SteerCancelAck { + // (undocumented) + readonly messageId: string; + // (undocumented) + readonly outcome: SteerCancelOutcome; +} + +// @public +export type SteerCancelOutcome = "retracted" | "none_pending"; + +// @public +export interface SteerControlOptions extends RequestOptions { + messageId?: string; +} + // @public export interface SteerEventPayload { // (undocumented) @@ -1447,6 +1550,9 @@ export interface SteerEventPayload { readonly text: string; } +// @public +export type SteerOutcome = "accepted" | "appended" | "too_late"; + // @public export interface SteerOutcomeEventPayload { // (undocumented) @@ -1900,6 +2006,24 @@ export interface WatchGapEnvelope { // @public export function withSessionAffinity(sessionId: string, options?: CallOptions): CallOptions; +// @public +export interface WorkspaceEnrollmentControls { + cancel(enrollmentId: string, options?: RequestOptions): Promise; + connect(options?: RequestOptions): Promise; + retry(enrollmentId: string, options?: RequestOptions): Promise; +} + +// @public +export interface WorkspaceEnrollmentState { + // (undocumented) + readonly enrollmentId: string; + readonly presentationUrl: string; + // (undocumented) + readonly requiredServices: number; + // (undocumented) + readonly status: string; +} + // @public export interface Worktrees { // Warning: (ae-forgotten-export) The symbol "ListWorktreesRequest" needs to be exported by the entry point node.d.ts diff --git a/sdk/typescript/etc/mecatl-sdk.api.md b/sdk/typescript/etc/mecatl-sdk.api.md index 57b7f6e9e6..d21eb7d941 100644 --- a/sdk/typescript/etc/mecatl-sdk.api.md +++ b/sdk/typescript/etc/mecatl-sdk.api.md @@ -82,6 +82,10 @@ export interface AttachedRun extends SessionActivity { export interface AttachOptions { from?: "now" | "start" | SdkCursor; includeLogOnly?: boolean; + onReconnect?: (info: { + readonly attempt: number; + readonly delayMs: number; + }) => void; signal?: AbortSignal; } @@ -597,6 +601,43 @@ export const MAX_PROMPT_MEDIA_BYTES: number; // @public export const MAX_PROMPT_MEDIA_PARTS = 16; +// @public +export interface McpAuthorization { + // (undocumented) + readonly authorizationId: string; + cancel(options?: RequestOptions): McpAuthorizationStream; + presentation(options?: RequestOptions): Promise; + recheck(options?: RequestOptions): McpAuthorizationStream; + // (undocumented) + readonly sessionId: string; +} + +// @public +export interface McpAuthorizationOutcome { + readonly result?: RunResult; +} + +// @public +export interface McpAuthorizationPresentation { + readonly url: string; +} + +// @public +export interface McpAuthorizationStream extends AsyncIterable { + close(): Promise; + result(): Promise; +} + +// @public +export interface McpConnectorStatus { + // (undocumented) + readonly catalogueState: string; + // (undocumented) + readonly name: string; + // (undocumented) + readonly toolCount: number; +} + // @public export interface McpInventory { // Warning: (ae-forgotten-export) The symbol "GetMcpPromptRequest" needs to be exported by the entry point index.d.ts @@ -767,6 +808,12 @@ export type PermissionAskResponder = (ask: PermissionAskEventPayload, signal: Ab // @public export type PermissionVerdict = "allow_once" | "allow_always" | "deny"; +// @public +export const PLAN_APPROVAL_TOOL = "PresentPlan"; + +// @public +export const PLAN_APPROVED_PROCEED_TEXT = "Plan approved by operator. Proceed with execution."; + // @public export class PlanApprovalRequiredError extends InvalidStateError { constructor(); @@ -874,6 +921,18 @@ export interface Run extends AsyncIterable { steer(text: string): Promise; } +// @public +export interface RunControls { + cancel(options?: RequestOptions): Promise; + cancelSteer(options?: SteerControlOptions): Promise; + resolveAsk(askId: string, verdict: PermissionVerdict, options?: RequestOptions): Promise; + // (undocumented) + readonly runId: string; + // (undocumented) + readonly sessionId: string; + steer(prompt: PromptInput, options?: SteerControlOptions): Promise; +} + // @public export interface RunOptions { onPermissionAsk?: PermissionAskResponder; @@ -1077,12 +1136,16 @@ export type ServerPosture = (typeof ServerPosture)[keyof typeof ServerPosture]; export interface Session { activity(options?: AttachOptions): Promise; attach(runId?: string, options?: AttachOptions): Promise; + cancelChild(childId: string, options?: RequestOptions): Promise; clear(options?: ClearSessionOptions, requestOptions?: RequestOptions): Promise; close(options?: RequestOptions): Promise; compact(options?: RequestOptions): Promise; + controls(runId: string): RunControls; delete(options?: RequestOptions): Promise; // (undocumented) readonly id: string; + mcpAuthorization(authorizationId: string): McpAuthorization; + mcpConnectors(options?: RequestOptions): Promise; rename(title: string, options?: RequestOptions): Promise; resolvePlan(verdict?: PlanApprovalVerdict): PlanResolution; retry(options?: RunOptions, requestOptions?: RequestOptions): Promise; @@ -1090,6 +1153,7 @@ export interface Session { setMode(mode: SessionMode, options?: RequestOptions): Promise; snapshot(options?: RequestOptions): Promise; transcript(options?: RequestOptions): Promise; + readonly workspaceEnrollment: WorkspaceEnrollmentControls; } // @public @@ -1131,6 +1195,20 @@ export interface SessionLimits { maxTurns?: number; } +// @public +export interface SessionMcpConnectors { + // (undocumented) + readonly availability: string; + // (undocumented) + readonly connectors: readonly McpConnectorStatus[]; + // (undocumented) + readonly enrollmentState: string; + // (undocumented) + readonly totalConnectors: number; + // (undocumented) + readonly truncated: boolean; +} + // @public export interface SessionMcpServer { command?: string; @@ -1341,6 +1419,31 @@ export interface Soul { get(request: GetSoulRequest, options?: RequestOptions): Promise; } +// @public +export interface SteerAck { + readonly messageId: string; + // (undocumented) + readonly outcome: SteerOutcome; + readonly promoted: boolean; + readonly runId: string; +} + +// @public +export interface SteerCancelAck { + // (undocumented) + readonly messageId: string; + // (undocumented) + readonly outcome: SteerCancelOutcome; +} + +// @public +export type SteerCancelOutcome = "retracted" | "none_pending"; + +// @public +export interface SteerControlOptions extends RequestOptions { + messageId?: string; +} + // @public export interface SteerEventPayload { // (undocumented) @@ -1351,6 +1454,9 @@ export interface SteerEventPayload { readonly text: string; } +// @public +export type SteerOutcome = "accepted" | "appended" | "too_late"; + // @public export interface SteerOutcomeEventPayload { // (undocumented) @@ -1764,6 +1870,24 @@ export interface WatchGapEnvelope { // @public export function withSessionAffinity(sessionId: string, options?: CallOptions): CallOptions; +// @public +export interface WorkspaceEnrollmentControls { + cancel(enrollmentId: string, options?: RequestOptions): Promise; + connect(options?: RequestOptions): Promise; + retry(enrollmentId: string, options?: RequestOptions): Promise; +} + +// @public +export interface WorkspaceEnrollmentState { + // (undocumented) + readonly enrollmentId: string; + readonly presentationUrl: string; + // (undocumented) + readonly requiredServices: number; + // (undocumented) + readonly status: string; +} + // @public export interface Worktrees { // Warning: (ae-forgotten-export) The symbol "ListWorktreesRequest" needs to be exported by the entry point index.d.ts diff --git a/sdk/typescript/src/authorization.ts b/sdk/typescript/src/authorization.ts new file mode 100644 index 0000000000..acf529a88e --- /dev/null +++ b/sdk/typescript/src/authorization.ts @@ -0,0 +1,397 @@ +import type { + DescMessage, + DescMethodStreaming, + DescMethodUnary, + MessageInitShape, + MessageShape, +} from "@bufbuild/protobuf"; +import type { CallOptions } from "@connectrpc/connect"; + +import { InvalidStateError, ProtocolError, type TransportKind } from "./errors.js"; +import { decodeEvent, type Event, type EventOf } from "./events.js"; +import { + HarnessService, + type ListSessionMcpConnectorsResponse, + type Event as ProtoEvent, + type WorkspaceEnrollment as ProtoWorkspaceEnrollment, +} from "./gen/mecatl/v1/harness_pb.js"; +import type { RequestOptions } from "./namespaces-core.js"; +import type { RunResult } from "./run.js"; + +/** The live browser URL for one pending MCP authorization. @public */ +export interface McpAuthorizationPresentation { + /** The URL to open in a browser. It is returned only by this live control and never enters an event or snapshot. */ + readonly url: string; +} + +/** The terminal outcome of an authorization recheck or cancellation stream. @public */ +export interface McpAuthorizationOutcome { + /** The terminal result of the continuation run, absent when the stream ended without one. */ + readonly result?: RunResult; +} + +/** + * The event stream an authorization control returns: the daemon's status + * events followed by the continuation run when the tool call resumes. + * Single-consumption: iterate it, or call result(). + * @public + */ +export interface McpAuthorizationStream extends AsyncIterable { + /** Drains the stream and returns the continuation's terminal, when one arrived. */ + result(): Promise; + /** Stops consuming the stream without cancelling the authorization. */ + close(): Promise; +} + +/** + * Controls for one pending per-tool MCP authorization (`authorization.required`). + * + * `presentation()` fetches the live browser URL; `recheck()` asks the daemon to + * re-inspect the authorization and streams the outcome (and the resumed run when + * it succeeds); `cancel()` abandons the authorization with the same stream shape. + * @public + */ +export interface McpAuthorization { + readonly authorizationId: string; + readonly sessionId: string; + /** + * Reads the live browser URL for this pending authorization. + * + * @param options - Request headers, cancellation signal, and deadline. + * @returns The URL the operator opens to authorize the tool. + */ + presentation(options?: RequestOptions): Promise; + /** + * Re-checks the authorization and streams status plus any continuation run. + * + * @param options - Request headers, cancellation signal, and deadline. + * @returns A single-consumption event stream. + */ + recheck(options?: RequestOptions): McpAuthorizationStream; + /** + * Cancels the authorization and streams the cancellation outcome plus any continuation run. + * + * @param options - Request headers, cancellation signal, and deadline. + * @returns A single-consumption event stream. + */ + cancel(options?: RequestOptions): McpAuthorizationStream; +} + +/** One broker-local MCP connector as the session sees it. @public */ +export interface McpConnectorStatus { + readonly name: string; + readonly catalogueState: string; + readonly toolCount: number; +} + +/** The session's broker-local MCP connector catalogue. @public */ +export interface SessionMcpConnectors { + readonly availability: string; + readonly enrollmentState: string; + readonly connectors: readonly McpConnectorStatus[]; + readonly totalConnectors: number; + readonly truncated: boolean; +} + +/** The safe projection of one workspace-services enrollment. @public */ +export interface WorkspaceEnrollmentState { + readonly enrollmentId: string; + readonly status: string; + readonly requiredServices: number; + /** The browser URL to complete the enrollment, "" when none is pending. */ + readonly presentationUrl: string; +} + +/** + * The pre-prompt workspace-services enrollment controls for one session. + * `connect()` begins an eligible enrollment or observes the pending one; `retry()` + * and `cancel()` act on one exact enrollment id and fail stale with a 412 problem. + * @public + */ +export interface WorkspaceEnrollmentControls { + /** + * Begins an eligible pre-prompt enrollment, or observes the exact pending one. + * + * @param options - Request headers, cancellation signal, and deadline. + * @returns The safe enrollment projection, including the browser URL while pending. + */ + connect(options?: RequestOptions): Promise; + /** + * Cancels the exact pending enrollment and begins its replacement. + * + * @param enrollmentId - The enrollment to replace. + * @param options - Request headers, cancellation signal, and deadline. + * @returns The replacement enrollment's projection. + * @throws `ServerError` with status 412 when the id is stale. + */ + retry(enrollmentId: string, options?: RequestOptions): Promise; + /** + * Cancels only the exact pending enrollment and clears its prompt gate. + * + * @param enrollmentId - The enrollment to cancel. + * @param options - Request headers, cancellation signal, and deadline. + * @returns The cancelled enrollment's projection. + * @throws `ServerError` with status 412 when the id is stale. + */ + cancel(enrollmentId: string, options?: RequestOptions): Promise; +} + +/** Internal seam shared with the client. */ +export interface AuthorizationOperations { + assertOpen(): void; + registerRun(cancel: () => Promise): () => void; + stream( + method: DescMethodStreaming, + input: AsyncIterable>, + options?: CallOptions, + ): AsyncIterable>; + readonly transportKind: TransportKind; + unary( + method: DescMethodUnary, + input: MessageInitShape, + options?: CallOptions, + ): Promise>; +} + +export function projectMcpConnectors( + value: ListSessionMcpConnectorsResponse, +): SessionMcpConnectors { + return { + availability: value.availability, + connectors: value.connectors.map((connector) => ({ + catalogueState: connector.catalogueState, + name: connector.name, + toolCount: connector.toolCount, + })), + enrollmentState: value.enrollmentState, + totalConnectors: value.totalConnectors, + truncated: value.truncated, + }; +} + +export function projectWorkspaceEnrollment( + value: ProtoWorkspaceEnrollment, +): WorkspaceEnrollmentState { + return { + enrollmentId: value.enrollmentId, + presentationUrl: value.presentationUrl, + requiredServices: value.requiredServices, + status: value.status, + }; +} + +async function* singleValue(value: T): AsyncGenerator { + yield value; +} + +type EventCarrier = { readonly event?: ProtoEvent | undefined }; + +class AuthorizationStreamImpl implements McpAuthorizationStream { + readonly #abort: AbortController; + readonly #frames: AsyncIterator; + readonly #operations: AuthorizationOperations; + readonly #release: () => void; + readonly #sessionId: string; + #claimed = false; + #ended = false; + #lastRunId = ""; + #terminal: EventOf<"result"> | undefined; + + constructor( + sessionId: string, + frames: AsyncIterator, + operations: AuthorizationOperations, + abort: AbortController, + release: () => void, + ) { + this.#sessionId = sessionId; + this.#frames = frames; + this.#operations = operations; + this.#abort = abort; + this.#release = release; + } + + [Symbol.asyncIterator](): AsyncIterator { + this.#claim(); + return { + next: () => this.#next(), + return: async () => { + await this.close(); + return { done: true, value: undefined }; + }, + }; + } + + async result(): Promise { + this.#claim(); + for (;;) { + const next = await this.#next(); + if (next.done) break; + } + const terminal = this.#terminal; + if (terminal === undefined) return {}; + return { + result: { + content: terminal.payload.text, + rawEvent: terminal, + runId: this.#lastRunId, + sessionId: this.#sessionId, + stopReason: terminal.payload.stop, + text: terminal.payload.text, + usage: terminal.payload.usage ?? terminal.usage, + }, + }; + } + + async close(): Promise { + if (this.#ended) return; + this.#ended = true; + this.#abort.abort(); + try { + await this.#frames.return?.(); + } catch { + // Releasing an aborted transport stream is best-effort. + } finally { + this.#release(); + } + } + + #claim(): void { + this.#operations.assertOpen(); + if (this.#claimed) { + throw new InvalidStateError("The authorization stream is already being consumed", { + transport: this.#operations.transportKind, + }); + } + this.#claimed = true; + } + + async #next(): Promise> { + this.#operations.assertOpen(); + if (this.#ended) return { done: true, value: undefined }; + let next: IteratorResult; + try { + next = await this.#frames.next(); + } catch (error) { + await this.close(); + throw error; + } + if (next.done) { + await this.close(); + return { done: true, value: undefined }; + } + const raw = next.value.event; + if (raw === undefined) { + await this.close(); + throw new ProtocolError("The authorization stream returned a frame without an event", { + transport: this.#operations.transportKind, + }); + } + const event = decodeEvent(raw, this.#operations.transportKind); + if (event.runId !== "") this.#lastRunId = event.runId; + if (event.kind === "result") this.#terminal = event; + return { done: false, value: event }; + } +} + +function openStream( + sessionId: string, + authorizationId: string, + action: "recheck" | "cancel", + operations: AuthorizationOperations, + options?: RequestOptions, +): McpAuthorizationStream { + operations.assertOpen(); + const abort = new AbortController(); + const signal = + options?.signal === undefined ? abort.signal : AbortSignal.any([abort.signal, options.signal]); + const callOptions = { ...options, signal }; + const input = singleValue({ authorizationId, sessionId }); + const iterable: AsyncIterable = + action === "recheck" + ? operations.stream(HarnessService.method.recheckMcpAuthorization, input, callOptions) + : operations.stream(HarnessService.method.cancelMcpAuthorization, input, callOptions); + const frames = iterable[Symbol.asyncIterator](); + let released = false; + let unregister: () => void = () => undefined; + const release = () => { + if (released) return; + released = true; + unregister(); + }; + const stream = new AuthorizationStreamImpl(sessionId, frames, operations, abort, release); + unregister = operations.registerRun(() => stream.close()); + return stream; +} + +/** Creates the control handle for one pending MCP authorization. */ +export function createMcpAuthorization( + sessionId: string, + authorizationId: string, + operations: AuthorizationOperations, +): McpAuthorization { + if (authorizationId === "") { + throw new InvalidStateError("An MCP authorization handle requires an authorization id", { + transport: operations.transportKind, + }); + } + return { + authorizationId, + sessionId, + async presentation(options) { + operations.assertOpen(); + const response = await operations.unary( + HarnessService.method.getMcpAuthorizationPresentation, + { authorizationId, sessionId }, + options, + ); + return { url: response.url }; + }, + recheck: (options) => openStream(sessionId, authorizationId, "recheck", operations, options), + cancel: (options) => openStream(sessionId, authorizationId, "cancel", operations, options), + }; +} + +/** Creates the workspace-enrollment controls for one session. */ +export function createWorkspaceEnrollmentControls( + sessionId: string, + operations: AuthorizationOperations, +): WorkspaceEnrollmentControls { + const guard = (enrollmentId: string) => { + operations.assertOpen(); + if (enrollmentId === "") { + throw new InvalidStateError("Workspace enrollment controls require an enrollment id", { + transport: operations.transportKind, + }); + } + }; + return { + async connect(options) { + operations.assertOpen(); + const response = await operations.unary( + HarnessService.method.connectWorkspaceServices, + { sessionId }, + options, + ); + return projectWorkspaceEnrollment(response); + }, + async retry(enrollmentId, options) { + guard(enrollmentId); + const response = await operations.unary( + HarnessService.method.retryWorkspaceEnrollment, + { enrollmentId, sessionId }, + options, + ); + return projectWorkspaceEnrollment(response); + }, + async cancel(enrollmentId, options) { + guard(enrollmentId); + const response = await operations.unary( + HarnessService.method.cancelWorkspaceEnrollment, + { enrollmentId, sessionId }, + options, + ); + return projectWorkspaceEnrollment(response); + }, + }; +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 2c6f5f5982..df096d2a21 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -2,12 +2,22 @@ import type { DescMessage, DescMethodStreaming, DescMethodUnary, + JsonValue, MessageInitShape, MessageShape, } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf"; import type { CallOptions, Transport } from "@connectrpc/connect"; - +import { + type AuthorizationOperations, + createMcpAuthorization, + createWorkspaceEnrollmentControls, + type McpAuthorization, + projectMcpConnectors, + type SessionMcpConnectors, + type WorkspaceEnrollmentControls, +} from "./authorization.js"; +import { createRunControls, type RunControls } from "./controls.js"; import { AuthenticationError, type DiagnosticsSink, @@ -282,6 +292,41 @@ export interface Session { * @throws `ServerError` when the session has no parked plan awaiting approval. */ resolvePlan(verdict?: PlanApprovalVerdict): PlanResolution; + /** + * Returns strict, run-id-addressed controls for one run of this session. + * + * The handle needs no event stream, so it also serves a run this client did + * not start (a durable watch, a re-attached page). HTTP transport only. + * + * @param runId - The run every control names through `expected_run_id`. + * @returns Controls that resolve asks, cancel, steer, and retract a steer. + */ + controls(runId: string): RunControls; + /** + * Cancels one running child (subagent, parallel branch, or team member) of this session's live run. + * + * @param childId - The child session id carried by `subagent.start`, `parallel.branch`, or `team.member`. + * @param options - Request headers, cancellation signal, and deadline. + * @returns A promise that resolves after the daemon accepts the cancellation. + * @throws `ServerError` with code `not_found` when the child is unknown or already finished. + */ + cancelChild(childId: string, options?: RequestOptions): Promise; + /** + * Inspects the session's broker-local MCP connector catalogue (no upstream probe). + * + * @param options - Request headers, cancellation signal, and deadline. + * @returns Availability, enrollment state, and the connector rows. + */ + mcpConnectors(options?: RequestOptions): Promise; + /** + * Returns the controls for one pending per-tool MCP authorization. + * + * @param authorizationId - The id carried by the `authorization.required` event. + * @returns Presentation, recheck, and cancel controls bound to that authorization. + */ + mcpAuthorization(authorizationId: string): McpAuthorization; + /** Pre-prompt workspace-services enrollment controls for this session. */ + readonly workspaceEnrollment: WorkspaceEnrollmentControls; /** * Releases runtime resources without removing the durable session. * @@ -414,6 +459,11 @@ interface SessionOperations { assertOpen(): void; attachmentStatus(): AttachmentStatusWriter; cancelRun(sessionId: string, runId: string): Promise; + control( + sessionId: string, + frame: ConverseFrame, + options?: RequestOptions, + ): Promise; readonly clientSignal: AbortSignal; features(): Promise>; invalidateCompatibility(): void; @@ -769,6 +819,63 @@ class SessionImpl implements Session { } } + async cancelChild(childId: string, options?: RequestOptions): Promise { + this.#operations.assertOpen(); + if (childId === "") { + throw new InvalidStateError("cancelChild requires a child id", { + transport: this.#operations.transportKind, + }); + } + await this.#operations.control( + this.id, + { kind: { case: "cancelChild", value: { childId } } }, + options, + ); + } + + async mcpConnectors(options?: RequestOptions): Promise { + this.#operations.assertOpen(); + const response = await this.#operations.unary( + HarnessService.method.listSessionMcpConnectors, + { sessionId: this.id }, + options, + ); + return projectMcpConnectors(response); + } + + mcpAuthorization(authorizationId: string): McpAuthorization { + return createMcpAuthorization(this.id, authorizationId, this.#authorizationOperations()); + } + + get workspaceEnrollment(): WorkspaceEnrollmentControls { + return createWorkspaceEnrollmentControls(this.id, this.#authorizationOperations()); + } + + #authorizationOperations(): AuthorizationOperations { + return { + assertOpen: () => this.#operations.assertOpen(), + registerRun: (cancel) => this.#operations.registerRun(cancel), + stream: (method, input, options) => this.#operations.stream(method, input, options), + transportKind: this.#operations.transportKind, + unary: (method, input, options) => this.#operations.unary(method, input, options), + }; + } + + controls(runId: string): RunControls { + this.#operations.assertOpen(); + if (runId === "") { + throw new InvalidStateError("Run controls require a run id", { + transport: this.#operations.transportKind, + }); + } + return createRunControls(this.id, runId, { + assertOpen: () => this.#operations.assertOpen(), + control: (sessionId, frame, options) => this.#operations.control(sessionId, frame, options), + promptCapabilities: () => this.#promptCapabilities, + transportKind: this.#operations.transportKind, + }); + } + async close(options?: RequestOptions): Promise { this.#operations.assertOpen(); await this.#operations.unary( @@ -853,6 +960,7 @@ class ClientImpl implements Client { attachmentStatus: () => this.#createAttachmentStatus(), cancelRun: (sessionId, runId) => this.#cancelRun(sessionId, runId), clientSignal: this.#abort.signal, + control: (sessionId, frame, options) => this.#control(sessionId, frame, options), features: () => this.#features(), invalidateCompatibility: () => invalidateRawCompatibility(this.#raw), registerAttachment: (close) => this.#register(this.#attachments, close), @@ -1162,6 +1270,40 @@ class ClientImpl implements Client { ); } + async #control( + sessionId: string, + frame: ConverseFrame, + options?: RequestOptions, + ): Promise { + this.#assertOpen(); + if (this.#transportKind === "grpc") { + throw new UnsupportedFeatureError("prompt_free_controls", { transport: "grpc" }); + } + const control = registeredTransportOperations(this.#transport)?.control; + if (control === undefined) { + throw new UnsupportedFeatureError("prompt_free_controls", { transport: "http" }); + } + const request = create(HarnessService.method.converse.input, frame); + const signal = + options?.signal === undefined + ? this.#abort.signal + : AbortSignal.any([this.#abort.signal, options.signal]); + if (this.#requestStatus === "offline") this.#setRequestStatus("reconnecting"); + try { + const ack = await control( + sessionId, + request, + signal, + sessionAffinityIfRepresentable(sessionId, options)?.headers, + ); + this.#setRequestStatus("online"); + return ack; + } catch (error) { + this.#observeError(error); + throw error; + } + } + async #cancelRun(sessionId: string, runId: string): Promise { this.#assertOpen(); if (this.#transportKind === "grpc") { diff --git a/sdk/typescript/src/controls.ts b/sdk/typescript/src/controls.ts new file mode 100644 index 0000000000..908d203a8d --- /dev/null +++ b/sdk/typescript/src/controls.ts @@ -0,0 +1,249 @@ +import type { JsonValue } from "@bufbuild/protobuf"; +import { create } from "@bufbuild/protobuf"; + +import { ProtocolError, type TransportKind } from "./errors.js"; +import { ContentSchema } from "./gen/mecatl/v1/harness_pb.js"; +import { encodePrompt, type PromptCapabilities, type PromptInput } from "./media.js"; +import type { RequestOptions } from "./namespaces-core.js"; +import { approvalVerdict, type ConverseFrame, type PermissionVerdict } from "./run.js"; + +/** The server's answer to a steer control. @public */ +export type SteerOutcome = "accepted" | "appended" | "too_late"; + +/** The server's answer to a cancel-steer control. @public */ +export type SteerCancelOutcome = "retracted" | "none_pending"; + +/** Options accepted by the steer and cancel-steer run controls. @public */ +export interface SteerControlOptions extends RequestOptions { + /** + * Client-minted correlation id. The acknowledgement echoes it, and the + * run's later `steer` drain echo names the id of the LAST message merged + * into the drained bundle (the watermark a client splits its queue on). + */ + messageId?: string; +} + +/** The acknowledgement of a steer control. @public */ +export interface SteerAck { + /** The request's own message id, echoed by the server ("" when none). */ + readonly messageId: string; + readonly outcome: SteerOutcome; + /** True when a too-late unqualified steer was promoted into a follow-up run. */ + readonly promoted: boolean; + /** The promoted follow-up run's id, "" when nothing was promoted. */ + readonly runId: string; +} + +/** The acknowledgement of a cancel-steer control. @public */ +export interface SteerCancelAck { + readonly messageId: string; + readonly outcome: SteerCancelOutcome; +} + +/** + * Prompt-free controls for one run, addressed by run id. + * + * Every control is STRICT: it names the run through `expected_run_id`, so a + * control that outlives its run is refused by the server as + * `stale_run_control` rather than acting on the session's next run. Unlike the + * controls on a `Run`, these do not require the run's own event stream, so + * they also serve a run this client re-attached to or observes through a + * durable watch. HTTP transport only. + * @public + */ +export interface RunControls { + readonly runId: string; + readonly sessionId: string; + /** + * Resolves one pending permission ask on this run. + * + * @param askId - ID carried by the permission ask. + * @param verdict - Decision to apply to the pending ask. + * @param options - Request headers, cancellation signal, and deadline. + * @throws `ServerError` with code `stale_run_control` when the run already ended. + */ + resolveAsk(askId: string, verdict: PermissionVerdict, options?: RequestOptions): Promise; + /** + * Requests cancellation of this run. + * + * @param options - Request headers, cancellation signal, and deadline. + * @throws `ServerError` with code `stale_run_control` when the run already ended. + */ + cancel(options?: RequestOptions): Promise; + /** + * Strictly steers this run with text or ordered text and media parts. + * + * @param prompt - Instruction to inject at the run's next turn boundary. + * @param options - Correlation id plus request headers, signal, and deadline. + * @returns The server's outcome; the caller keeps the text on `too_late`. + * @throws `UnsupportedFeatureError` when the server lacks `http_steer`. + * @throws `ServerError` with code `stale_run_control` when the run already ended. + */ + steer(prompt: PromptInput, options?: SteerControlOptions): Promise; + /** + * Retracts this run's pending, un-drained steer bundle. + * + * @param options - Correlation id plus request headers, signal, and deadline. + * @returns `retracted`, or `none_pending` when nothing was waiting. + * @throws `UnsupportedFeatureError` when the server lacks `http_steer`. + */ + cancelSteer(options?: SteerControlOptions): Promise; +} + +/** Internal seam the client hands the controls: one control send plus prompt capabilities. */ +export interface RunControlOperations { + assertOpen(): void; + control( + sessionId: string, + frame: ConverseFrame, + options?: RequestOptions, + ): Promise; + promptCapabilities(): PromptCapabilities | undefined; + readonly transportKind: TransportKind; +} + +function ackRecord(value: JsonValue | undefined): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + return value as Record; +} + +function ackString(record: Record, key: string): string { + const value = record[key]; + return typeof value === "string" ? value : ""; +} + +function steerOutcome(value: string, transport: TransportKind): SteerOutcome { + switch (value) { + case "accepted": + case "appended": + case "too_late": + return value; + default: + throw new ProtocolError(`The steer acknowledgement carried an unknown outcome ${value}`, { + transport, + }); + } +} + +function steerCancelOutcome(value: string, transport: TransportKind): SteerCancelOutcome { + switch (value) { + case "retracted": + case "none_pending": + return value; + default: + throw new ProtocolError( + `The cancel-steer acknowledgement carried an unknown outcome ${value}`, + { transport }, + ); + } +} + +class RunControlsImpl implements RunControls { + readonly runId: string; + readonly sessionId: string; + readonly #operations: RunControlOperations; + + constructor(sessionId: string, runId: string, operations: RunControlOperations) { + this.runId = runId; + this.sessionId = sessionId; + this.#operations = operations; + } + + async resolveAsk( + askId: string, + verdict: PermissionVerdict, + options?: RequestOptions, + ): Promise { + this.#operations.assertOpen(); + await this.#operations.control( + this.sessionId, + { + kind: { + case: "resumeApproval", + value: { + allow: verdict !== "deny", + askId, + expectedRunId: this.runId, + verdict: approvalVerdict(verdict, this.#operations.transportKind), + }, + }, + }, + options, + ); + } + + async cancel(options?: RequestOptions): Promise { + this.#operations.assertOpen(); + await this.#operations.control( + this.sessionId, + { kind: { case: "cancel", value: { expectedRunId: this.runId } } }, + options, + ); + } + + async steer(prompt: PromptInput, options: SteerControlOptions = {}): Promise { + this.#operations.assertOpen(); + const { messageId = "", ...requestOptions } = options; + const encoded = encodePrompt(prompt, this.#operations.promptCapabilities()); + const ack = ackRecord( + await this.#operations.control( + this.sessionId, + { + kind: { + case: "steer", + value: { + expectedRunId: this.runId, + messageId, + parts: encoded.media.map((part) => + create(ContentSchema, { + ...(part.bytes === undefined ? {} : { data: part.bytes }), + kind: part.kind === "image" ? 1 : 2, + mimeType: part.mimeType, + ...(part.url === undefined ? {} : { url: part.url }), + }), + ), + text: encoded.text, + }, + }, + }, + requestOptions, + ), + ); + return { + messageId: ackString(ack, "message_id") || messageId, + outcome: steerOutcome(ackString(ack, "outcome"), this.#operations.transportKind), + promoted: ack.promoted === true, + runId: ackString(ack, "run_id"), + }; + } + + async cancelSteer(options: SteerControlOptions = {}): Promise { + this.#operations.assertOpen(); + const { messageId = "", ...requestOptions } = options; + const ack = ackRecord( + await this.#operations.control( + this.sessionId, + { + kind: { + case: "steerCancel", + value: { expectedRunId: this.runId, messageId }, + }, + }, + requestOptions, + ), + ); + return { + messageId: ackString(ack, "message_id") || messageId, + outcome: steerCancelOutcome(ackString(ack, "outcome"), this.#operations.transportKind), + }; + } +} + +/** Creates the run-id-addressed control handle for one session run. */ +export function createRunControls( + sessionId: string, + runId: string, + operations: RunControlOperations, +): RunControls { + return new RunControlsImpl(sessionId, runId, operations); +} diff --git a/sdk/typescript/src/http.ts b/sdk/typescript/src/http.ts index 690f9fdedd..05b8b58232 100644 --- a/sdk/typescript/src/http.ts +++ b/sdk/typescript/src/http.ts @@ -26,6 +26,7 @@ import { resolveHTTPOnlyControl, resolveHTTPRoute, } from "./rpc-catalog.js"; +import { normalizeWellKnownJson } from "./wire-json.js"; /** Options for the browser-compatible HTTP and SSE transport. @public */ export interface HttpTransportOptions extends CredentialOptions { @@ -129,6 +130,17 @@ function normalizeUnaryResponse( return raw; } +function wrapsEvent(output: DescMessage): boolean { + if (output.fields.length !== 1) return false; + const field = output.fields[0]; + return ( + field !== undefined && + field.name === "event" && + field.fieldKind === "message" && + field.message.typeName === "mecatl.v1.Event" + ); +} + function timeoutSignal( signal: AbortSignal | undefined, timeoutMs: number | undefined, @@ -248,7 +260,10 @@ class HttpTransport implements Transport { }); } } - const normalized = normalizeUnaryResponse(resolved.classification, raw); + const normalized = normalizeWellKnownJson( + method.output, + normalizeUnaryResponse(resolved.classification, raw), + ); let message: MessageShape; try { message = fromJson(method.output, normalized, { ignoreUnknownFields: true }); @@ -284,6 +299,20 @@ class HttpTransport implements Transport { signal: AbortSignal | undefined, requestHeaders?: HeadersInit, ): Promise { + await this.control(sessionId, frame, signal, requestHeaders); + } + + /** + * Sends one prompt-free control for a session over its unary HTTP route and + * returns the server's JSON acknowledgement (`{}` for a bodiless 204). The + * same rewrite serves the Converse control lane, so the two never drift. + */ + async control( + sessionId: string, + frame: ConverseRequest, + signal: AbortSignal | undefined, + requestHeaders?: HeadersInit, + ): Promise { const kind = frame.kind; let route: Route; let body: JsonRecord; @@ -299,7 +328,7 @@ class HttpTransport implements Transport { break; case "cancel": await this.cancelRun(sessionId, kind.value.expectedRunId, signal, requestHeaders); - return; + return {}; case "cancelChild": route = sessionControlRoute("cancelChild", sessionId); body = { child_id: kind.value.childId }; @@ -338,6 +367,13 @@ class HttpTransport implements Transport { } const response = await this.#request(route, body, signal, requestHeaders); if (!response.ok) await this.#problem(response); + if (response.status === 204) return {}; + try { + return (await response.json()) as JsonValue; + } catch { + // A control acknowledgement without a JSON body still succeeded. + return {}; + } } async cancelRun( @@ -370,7 +406,12 @@ class HttpTransport implements Transport { const effectiveSignal = timeoutSignal(signal, timeoutMs); let route: Route; let body: JsonRecord = {}; - let wrapEvent = false; + // The daemon streams BARE Event frames on every SSE route whose response + // message is a single-field `event` envelope (Converse, the MCP + // authorization controls). Wrap those so fromJson sees the envelope; a + // response that already is the frame (WatchSessionEvents' {event,cursor, + // phase}, ApprovePlan's bare Event) passes through untouched. + const wrapEvent = wrapsEvent(method.output); let startControls: (() => Promise) | undefined; let controlFailure: Promise = new Promise(() => undefined); @@ -383,7 +424,6 @@ class HttpTransport implements Transport { }); } const sessionId = start.value.sessionId; - wrapEvent = true; if (start.case === "prompt") { route = sessionControlRoute("prompt", sessionId); body = { @@ -457,7 +497,7 @@ class HttpTransport implements Transport { response.headers.get("x-request-id") ?? undefined, ); } - const normalized = wrapEvent ? { event: raw } : raw; + const normalized = normalizeWellKnownJson(output, wrapEvent ? { event: raw } : raw); let message: MessageShape; try { message = fromJson(output, normalized, { ignoreUnknownFields: true }); @@ -560,5 +600,7 @@ export function createHttpTransport(options: HttpTransportOptions): Transport { const transport = new HttpTransport(options); return registerTransport(transport, "http", { cancelRun: (sessionId, runId, signal) => transport.cancelRun(sessionId, runId, signal), + control: (sessionId, frame, signal, requestHeaders) => + transport.control(sessionId, frame, signal, requestHeaders), }); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index cd38294eb9..ecd37577f8 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -5,6 +5,16 @@ */ export type { Transport } from "@connectrpc/connect"; +export type { + McpAuthorization, + McpAuthorizationOutcome, + McpAuthorizationPresentation, + McpAuthorizationStream, + McpConnectorStatus, + SessionMcpConnectors, + WorkspaceEnrollmentControls, + WorkspaceEnrollmentState, +} from "./authorization.js"; export type { ClearSessionOptions, Client, @@ -21,6 +31,14 @@ export type { Sessions, } from "./client.js"; export { connect } from "./client.js"; +export type { + RunControls, + SteerAck, + SteerCancelAck, + SteerCancelOutcome, + SteerControlOptions, + SteerOutcome, +} from "./controls.js"; export type { CredentialOptions, CredentialProvider } from "./credentials.js"; export type { ClientDiagnosticsOptions, @@ -146,6 +164,7 @@ export type { PlanResolution, PlanResolutionResult, } from "./plan.js"; +export { PLAN_APPROVAL_TOOL, PLAN_APPROVED_PROCEED_TEXT } from "./plan.js"; export type { RawClient, RawClientOptions } from "./raw.js"; export { createRawClient, diff --git a/sdk/typescript/src/plan.ts b/sdk/typescript/src/plan.ts index 839ca5549b..45971cf467 100644 --- a/sdk/typescript/src/plan.ts +++ b/sdk/typescript/src/plan.ts @@ -61,8 +61,18 @@ export interface PlanResolutionOperations { type ConsumptionMode = "events" | "result"; type ResolutionPhase = "resumed" | "after-resumed" | "continuation" | "after-continuation"; +/** + * The daemon's plan-approval tool name: a permission ask for this tool is a + * plan presented for operator review rather than an ordinary tool call. + * @public + */ export const PLAN_APPROVAL_TOOL = "PresentPlan"; +/** + * The exact prompt the daemon expects when an approved plan continues into + * execution; clients that start the continuation themselves must send it verbatim. + * @public + */ // BEGIN MECATL_PLAN_APPROVED_PROCEED_TEXT export const PLAN_APPROVED_PROCEED_TEXT = "Plan approved by operator. Proceed with execution."; // END MECATL_PLAN_APPROVED_PROCEED_TEXT diff --git a/sdk/typescript/src/raw.ts b/sdk/typescript/src/raw.ts index 8dfa0c0e0e..8e0efee932 100644 --- a/sdk/typescript/src/raw.ts +++ b/sdk/typescript/src/raw.ts @@ -15,7 +15,7 @@ import { ServerError, type TransportKind, } from "./errors.js"; -import type { GetCompatibilityInfoResponse } from "./gen/mecatl/v1/harness_pb.js"; +import type { ConverseRequest, GetCompatibilityInfoResponse } from "./gen/mecatl/v1/harness_pb.js"; import { HarnessService } from "./gen/mecatl/v1/harness_pb.js"; /** Canonical routing hint for session-bound Mecatl requests. It grants no authority. @public */ @@ -89,6 +89,16 @@ const compatibilityReaders = new WeakMap(); interface TransportOperations { cancelRun(sessionId: string, runId: string, signal: AbortSignal): Promise; + /** + * Sends one prompt-free control (approve, cancel, steer, cancel-steer) for a + * session and returns the server's JSON acknowledgement, when it sent one. + */ + control( + sessionId: string, + frame: ConverseRequest, + signal: AbortSignal, + requestHeaders?: HeadersInit, + ): Promise; } interface CompatibilityResult { diff --git a/sdk/typescript/src/rpc-catalog.ts b/sdk/typescript/src/rpc-catalog.ts index ddb4af320d..1214d49a06 100644 --- a/sdk/typescript/src/rpc-catalog.ts +++ b/sdk/typescript/src/rpc-catalog.ts @@ -1082,7 +1082,7 @@ const rpcCatalogRows = [ "/v1/sessions/{id}/mcp-authorizations/{authorization_id}/recheck", ["id=session_id", "authorization_id=authorization_id"], [], - "json", + "none", "sse", ), }), @@ -1098,7 +1098,7 @@ const rpcCatalogRows = [ "/v1/sessions/{id}/mcp-authorizations/{authorization_id}/cancel", ["id=session_id", "authorization_id=authorization_id"], [], - "json", + "none", "sse", ), }), diff --git a/sdk/typescript/src/run.ts b/sdk/typescript/src/run.ts index 418b5e96ec..eaecd88918 100644 --- a/sdk/typescript/src/run.ts +++ b/sdk/typescript/src/run.ts @@ -407,7 +407,10 @@ export class RunImpl implements Run { } } -function approvalVerdict(verdict: PermissionVerdict, transport: TransportKind): ApprovalVerdict { +export function approvalVerdict( + verdict: PermissionVerdict, + transport: TransportKind, +): ApprovalVerdict { switch (verdict) { case "allow_once": return ApprovalVerdict.ALLOW_ONCE; diff --git a/sdk/typescript/src/watch.ts b/sdk/typescript/src/watch.ts index c6ebfcac23..b489a913ae 100644 --- a/sdk/typescript/src/watch.ts +++ b/sdk/typescript/src/watch.ts @@ -52,6 +52,12 @@ export interface AttachOptions { includeLogOnly?: boolean; /** Detaches this view when aborted; it never cancels a run. */ signal?: AbortSignal; + /** + * Called before each resumable reconnect attempt (never for the first + * connection, never after detach) with the 1-based attempt number and the + * backoff delay about to be waited. + */ + onReconnect?: (info: { readonly attempt: number; readonly delayMs: number }) => void; } /** A replayed or live durable event. @public */ @@ -364,6 +370,7 @@ class WatchConnection { readonly #sessionId: string; readonly #signal: AbortSignal; readonly #status: AttachmentStatusWriter; + readonly #onReconnect: AttachOptions["onReconnect"]; #attempt = 0; #closed = false; #source: AsyncIterator | undefined; @@ -374,7 +381,9 @@ class WatchConnection { operations: AttachmentOperations, externalSignal: AbortSignal | undefined, internal: AttachmentInternalOptions, + onReconnect?: AttachOptions["onReconnect"], ) { + this.#onReconnect = onReconnect; this.#sessionId = sessionId; this.#runId = runId; this.#operations = operations; @@ -441,6 +450,11 @@ class WatchConnection { while (!this.#closed && !this.#signal.aborted) { const delay = this.#scheduler.delayFor(this.#attempt); this.#attempt += 1; + try { + this.#onReconnect?.({ attempt: this.#attempt, delayMs: delay }); + } catch { + // A listener must never break the reconnect loop. + } try { await this.#scheduler.sleep(delay, this.#signal); } catch (error) { @@ -730,7 +744,14 @@ export async function createSessionActivity( if (resume !== undefined) requireCursorScope(resume, "", ""); await requireWatchFeature(operations); const token = resume?.token ?? ""; - const connection = new WatchConnection(sessionId, "", operations, options.signal, internal); + const connection = new WatchConnection( + sessionId, + "", + operations, + options.signal, + internal, + options.onReconnect, + ); return new SessionActivityImpl( connection, operations.transportKind, @@ -772,6 +793,7 @@ export async function createAttachedRun( operations, options.signal, internal, + options.onReconnect, ); return new AttachedRunImpl( sessionId, @@ -796,6 +818,7 @@ export async function createAttachedRun( operations, options.signal, internal, + options.onReconnect, ); if (runId !== undefined) { return new AttachedRunImpl( diff --git a/sdk/typescript/src/wire-json.ts b/sdk/typescript/src/wire-json.ts new file mode 100644 index 0000000000..8467be65f6 --- /dev/null +++ b/sdk/typescript/src/wire-json.ts @@ -0,0 +1,133 @@ +import type { DescField, DescMessage, JsonObject, JsonValue } from "@bufbuild/protobuf"; + +/** + * The daemon's HTTP surface marshals responses with Go's stdlib encoding/json + * over the generated proto structs, not protojson. Every scalar and enum still + * decodes (snake_case names, integer enums, int64 as number), but the two + * well-known types disagree structurally: a `google.protobuf.Timestamp` + * arrives as `{"seconds": N, "nanos": M}` and a `google.protobuf.Duration` as + * `{"seconds": N, "nanos": M}`, whereas protobuf-es `fromJson` accepts only + * the RFC 3339 string and the `"1.5s"` string respectively. + * + * This walker rewrites exactly those two shapes, guided by the response + * message descriptor, so the transport speaks the daemon's actual encoding — + * the same discipline as its enum and permission-mode translation. Values that + * already are protojson strings pass through untouched, and the input is never + * mutated (the exact received JSON stays registered for `getRawJson`). + */ + +const TIMESTAMP = "google.protobuf.Timestamp"; +const DURATION = "google.protobuf.Duration"; + +function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function structSeconds(value: JsonObject): { nanos: number; seconds: bigint } | undefined { + for (const key of Object.keys(value)) { + if (key !== "seconds" && key !== "nanos") return undefined; + } + const seconds = value.seconds; + const nanos = value.nanos; + let secondsValue: bigint; + if (seconds === undefined) secondsValue = 0n; + else if (typeof seconds === "number" && Number.isInteger(seconds)) secondsValue = BigInt(seconds); + else if (typeof seconds === "string" && /^-?\d+$/.test(seconds)) secondsValue = BigInt(seconds); + else return undefined; + let nanosValue: number; + if (nanos === undefined) nanosValue = 0; + else if (typeof nanos === "number" && Number.isInteger(nanos)) nanosValue = nanos; + else if (typeof nanos === "string" && /^-?\d+$/.test(nanos)) nanosValue = Number(nanos); + else return undefined; + return { nanos: nanosValue, seconds: secondsValue }; +} + +function fraction(nanos: number): string { + if (nanos === 0) return ""; + const digits = String(Math.abs(nanos)).padStart(9, "0"); + const trimmed = digits.endsWith("000000") + ? digits.slice(0, 3) + : digits.endsWith("000") + ? digits.slice(0, 6) + : digits; + return `.${trimmed}`; +} + +function timestampString(value: JsonObject): JsonValue | undefined { + const parts = structSeconds(value); + if (parts === undefined) return undefined; + const millis = Number(parts.seconds) * 1000; + if (!Number.isSafeInteger(millis)) return undefined; + const date = new Date(millis); + if (Number.isNaN(date.getTime())) return undefined; + const base = date.toISOString().slice(0, 19); + return `${base}${fraction(parts.nanos)}Z`; +} + +function durationString(value: JsonObject): JsonValue | undefined { + const parts = structSeconds(value); + if (parts === undefined) return undefined; + const negative = parts.seconds < 0n || parts.nanos < 0; + const seconds = parts.seconds < 0n ? -parts.seconds : parts.seconds; + return `${negative ? "-" : ""}${seconds.toString()}${fraction(parts.nanos)}s`; +} + +function normalizeMessage(desc: DescMessage, value: JsonValue): JsonValue { + if (!isObject(value)) return value; + if (desc.typeName === TIMESTAMP) return timestampString(value) ?? value; + if (desc.typeName === DURATION) return durationString(value) ?? value; + let copy: JsonObject | undefined; + for (const field of desc.fields) { + const key = + field.name in value ? field.name : field.jsonName in value ? field.jsonName : undefined; + if (key === undefined) continue; + const current = value[key]; + if (current === undefined) continue; + const next = normalizeField(field, current); + if (next !== current) { + copy ??= { ...value }; + copy[key] = next; + } + } + return copy ?? value; +} + +function normalizeField(field: DescField, value: JsonValue): JsonValue { + switch (field.fieldKind) { + case "message": + return normalizeMessage(field.message, value); + case "list": { + if (field.listKind !== "message" || !Array.isArray(value)) return value; + let changed = false; + const items = value.map((item) => { + const next = normalizeMessage(field.message, item); + if (next !== item) changed = true; + return next; + }); + return changed ? items : value; + } + case "map": { + if (field.mapKind !== "message" || !isObject(value)) return value; + let copy: JsonObject | undefined; + for (const [key, item] of Object.entries(value)) { + const next = normalizeMessage(field.message, item); + if (next !== item) { + copy ??= { ...value }; + copy[key] = next; + } + } + return copy ?? value; + } + default: + return value; + } +} + +/** + * Rewrites stdlib-JSON well-known types inside a response into their protojson + * spellings so `fromJson(desc, …)` accepts them. Returns the input itself when + * nothing needed rewriting. + */ +export function normalizeWellKnownJson(desc: DescMessage, value: JsonValue): JsonValue { + return normalizeMessage(desc, value); +} diff --git a/sdk/typescript/test/attach-reconnect.test.ts b/sdk/typescript/test/attach-reconnect.test.ts index 4bcd4e390d..d9c425a040 100644 --- a/sdk/typescript/test/attach-reconnect.test.ts +++ b/sdk/typescript/test/attach-reconnect.test.ts @@ -245,6 +245,52 @@ describe("attachment reconnect authority", () => { expect(delays[0]).toBe(75); }); + it("onReconnect reports each attempt and its delay, and a throwing listener never breaks the loop", async () => { + vi.spyOn(Math, "random").mockReturnValue(0); + const delays: number[] = []; + const notices: { attempt: number; delayMs: number }[] = []; + let watches = 0; + const clientAbort = new AbortController(); + const operations = { + cancelRun: async () => undefined, + clientSignal: clientAbort.signal, + features: async () => new Set([watchFeature]), + invalidateCompatibility: () => undefined, + transportKind: "grpc" as const, + watch: () => { + watches += 1; + if (watches > 4) throw terminalError("cursor_expired"); + return failedWatch(new TransportError("dropped", { transport: "grpc" })); + }, + }; + const attached = await createAttachedRun( + sessionId, + runId, + operations, + { + onReconnect: (info) => { + notices.push({ ...info }); + throw new Error("listener bug"); + }, + }, + { + scheduler: { + sleep: async (ms) => { + delays.push(ms); + }, + }, + }, + ); + + await expect(attached[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + code: "cursor_expired", + }); + + expect(notices.map((notice) => notice.attempt)).toEqual([1, 2, 3, 4]); + expect(notices.map((notice) => notice.delayMs)).toEqual(delays); + expect(delays).toHaveLength(4); + }); + it("a lagging watch is resumable and reconnects rather than failing", async () => { const requests: string[] = []; const transport = watchTransport(async function* (request) { diff --git a/sdk/typescript/test/mcp-authorization.test.ts b/sdk/typescript/test/mcp-authorization.test.ts new file mode 100644 index 0000000000..35b1997c4f --- /dev/null +++ b/sdk/typescript/test/mcp-authorization.test.ts @@ -0,0 +1,269 @@ +import { createRouterTransport } from "@connectrpc/connect"; +import { describe, expect, it } from "vitest"; + +import { HarnessService } from "../src/gen/mecatl/v1/harness_pb.js"; +import { + type Client, + connect, + createHttpTransport, + type Event, + InvalidStateError, + ServerError, +} from "../src/index.js"; + +const sessionId = "session-authz"; +const authorizationId = "authz-7"; + +interface RecordedRequest { + readonly body: Record | undefined; + readonly method: string; + readonly path: string; + readonly rawBody: BodyInit | null | undefined; +} + +function sse(frames: readonly unknown[]): Response { + return new Response(frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); +} + +function httpHarness( + options: { + readonly features?: readonly string[]; + readonly respond?: (path: string, method: string) => Response | undefined; + } = {}, +): { readonly client: Client; readonly requests: RecordedRequest[] } { + const requests: RecordedRequest[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + requests.push({ body, method, path: url.pathname, rawBody: init?.body }); + if (url.pathname === "/v1/compatibility") { + return Response.json({ api_major: 1, features: options.features ?? ["http_steer"] }); + } + if (url.pathname === `/v1/sessions/${sessionId}` && method === "GET") { + return Response.json({ session_id: sessionId }); + } + const scripted = options.respond?.(url.pathname, method); + if (scripted !== undefined) return scripted; + return Response.json({ code: "not_found", detail: "not found" }, { status: 404 }); + }; + const transport = createHttpTransport({ baseUrl: "http://mecatl.test", fetch }); + return { client: connect({ transport, transportKind: "http" }), requests }; +} + +async function collect(source: AsyncIterable): Promise { + const events: Event[] = []; + for await (const value of source) events.push(value); + return events; +} + +describe("session.mcpAuthorization (HTTP)", () => { + it("reads the live presentation URL", async () => { + const { client, requests } = httpHarness({ + respond: (path) => + path === `/v1/sessions/${sessionId}/mcp-authorizations/${authorizationId}/presentation` + ? Response.json({ url: "https://idp.example/authorize?state=abc" }) + : undefined, + }); + const session = await client.sessions.get(sessionId); + await expect(session.mcpAuthorization(authorizationId).presentation()).resolves.toEqual({ + url: "https://idp.example/authorize?state=abc", + }); + expect(requests.at(-1)).toMatchObject({ + method: "GET", + path: `/v1/sessions/${sessionId}/mcp-authorizations/${authorizationId}/presentation`, + }); + await client.close(); + }); + + it("recheck streams the daemon's bare events and reports the continuation terminal", async () => { + const { client, requests } = httpHarness({ + respond: (path, method) => + method === "POST" && + path === `/v1/sessions/${sessionId}/mcp-authorizations/${authorizationId}/recheck` + ? sse([ + { + type: "authorization.resolved", + authorization: { authorization_id: authorizationId, status: "authorized" }, + }, + { run_id: "run-2", type: "message.delta", text: "resumed" }, + { run_id: "run-2", type: "result", result: { stop: "end_turn", text: "done" } }, + ]) + : undefined, + }); + const session = await client.sessions.get(sessionId); + const kinds = (await collect(session.mcpAuthorization(authorizationId).recheck())).map( + (event) => event.kind, + ); + expect(kinds).toEqual(["authorization.resolved", "message.delta", "result"]); + const outcome = await session.mcpAuthorization(authorizationId).recheck().result(); + expect(outcome.result).toMatchObject({ runId: "run-2", stopReason: "end_turn", text: "done" }); + const recheck = requests.find((request) => request.path.endsWith("/recheck")); + // The daemon rejects any body on the authorization controls (HTTP 400). + expect(recheck?.body).toBeUndefined(); + expect(recheck?.rawBody).toBeUndefined(); + await client.close(); + }); + + it("cancel streams the cancellation outcome", async () => { + const { client, requests } = httpHarness({ + respond: (path, method) => + method === "POST" && + path === `/v1/sessions/${sessionId}/mcp-authorizations/${authorizationId}/cancel` + ? sse([ + { + type: "authorization.resolved", + authorization: { authorization_id: authorizationId, status: "cancelled" }, + }, + ]) + : undefined, + }); + const session = await client.sessions.get(sessionId); + const outcome = await session.mcpAuthorization(authorizationId).cancel().result(); + expect(outcome.result).toBeUndefined(); + const cancel = requests.find( + (request) => request.path.endsWith("/cancel") && request.method === "POST", + ); + expect(cancel).toBeDefined(); + expect(cancel?.rawBody).toBeUndefined(); + await client.close(); + }); + + it("surfaces a stale authorization as the server's typed error", async () => { + const { client } = httpHarness({ + respond: (path) => + path.endsWith("/presentation") + ? Response.json( + { + code: "failed_precondition", + detail: "authorization is no longer pending", + status: 412, + }, + { headers: { "content-type": "application/problem+json" }, status: 412 }, + ) + : undefined, + }); + const session = await client.sessions.get(sessionId); + await expect(session.mcpAuthorization(authorizationId).presentation()).rejects.toBeInstanceOf( + ServerError, + ); + await client.close(); + }); + + it("refuses an empty authorization id", async () => { + const { client } = httpHarness(); + const session = await client.sessions.get(sessionId); + expect(() => session.mcpAuthorization("")).toThrow(InvalidStateError); + await client.close(); + }); +}); + +describe("session.mcpAuthorization (gRPC)", () => { + it("streams the envelope's events over a protobuf transport", async () => { + const transport = createRouterTransport((router) => { + router.service(HarnessService, { + getCompatibilityInfo: () => ({ apiMajor: 1, features: [] }), + getSession: () => ({ session: { sessionId } }), + recheckMcpAuthorization: async function* (requests) { + for await (const request of requests) { + expect(request.authorizationId).toBe(authorizationId); + } + yield { event: { runId: "run-9", text: "resumed", type: "message.delta" } }; + yield { + event: { result: { stop: "end_turn", text: "ok" }, runId: "run-9", type: "result" }, + }; + }, + }); + }); + const client = connect({ transport }); + const session = await client.sessions.get(sessionId); + const outcome = await session.mcpAuthorization(authorizationId).recheck().result(); + expect(outcome.result).toMatchObject({ runId: "run-9", stopReason: "end_turn" }); + await client.close(); + }); +}); + +describe("session.mcpConnectors and workspaceEnrollment (HTTP)", () => { + it("projects the connector catalogue", async () => { + const { client } = httpHarness({ + respond: (path) => + path === `/v1/sessions/${sessionId}/mcp/connectors` + ? Response.json({ + availability: "available", + enrollment_state: "enrolled", + connectors: [{ name: "github", catalogue_state: "ready", tool_count: 12 }], + total_connectors: 1, + truncated: false, + }) + : undefined, + }); + const session = await client.sessions.get(sessionId); + await expect(session.mcpConnectors()).resolves.toEqual({ + availability: "available", + connectors: [{ catalogueState: "ready", name: "github", toolCount: 12 }], + enrollmentState: "enrolled", + totalConnectors: 1, + truncated: false, + }); + await client.close(); + }); + + it("connect, retry, and cancel hit their routes and project the enrollment", async () => { + const enrollment = { + enrollment_id: "enr-1", + status: "pending", + required_services: 2, + presentation_url: "https://gateway.example/enroll/enr-1", + }; + const { client, requests } = httpHarness({ + respond: (path, method) => + method === "POST" && path.startsWith(`/v1/sessions/${sessionId}/workspace-enrollment/`) + ? Response.json(enrollment) + : undefined, + }); + const session = await client.sessions.get(sessionId); + const expected = { + enrollmentId: "enr-1", + presentationUrl: "https://gateway.example/enroll/enr-1", + requiredServices: 2, + status: "pending", + }; + await expect(session.workspaceEnrollment.connect()).resolves.toEqual(expected); + await expect(session.workspaceEnrollment.retry("enr-1")).resolves.toEqual(expected); + await expect(session.workspaceEnrollment.cancel("enr-1")).resolves.toEqual(expected); + expect( + requests.filter((request) => request.method === "POST").map((request) => request.path), + ).toEqual([ + `/v1/sessions/${sessionId}/workspace-enrollment/connect`, + `/v1/sessions/${sessionId}/workspace-enrollment/enr-1/retry`, + `/v1/sessions/${sessionId}/workspace-enrollment/enr-1/cancel`, + ]); + await expect(session.workspaceEnrollment.retry("")).rejects.toBeInstanceOf(InvalidStateError); + await client.close(); + }); +}); + +describe("session.cancelChild (HTTP)", () => { + it("posts the child id to cancel-child", async () => { + const { client, requests } = httpHarness({ + respond: (path, method) => + method === "POST" && path === `/v1/sessions/${sessionId}/cancel-child` + ? new Response(null, { status: 204 }) + : undefined, + }); + const session = await client.sessions.get(sessionId); + await session.cancelChild("subagent-abc"); + expect(requests.at(-1)).toMatchObject({ + body: { child_id: "subagent-abc" }, + method: "POST", + path: `/v1/sessions/${sessionId}/cancel-child`, + }); + await expect(session.cancelChild("")).rejects.toBeInstanceOf(InvalidStateError); + await client.close(); + }); +}); diff --git a/sdk/typescript/test/package.test.ts b/sdk/typescript/test/package.test.ts index b858ca9df6..7a7f1ae275 100644 --- a/sdk/typescript/test/package.test.ts +++ b/sdk/typescript/test/package.test.ts @@ -184,10 +184,18 @@ test("packed tarball carries dist and license only", () => { const expectedFiles = [ "package/LICENSE", "package/README.md", + "package/dist/authorization.d.ts", + "package/dist/authorization.d.ts.map", + "package/dist/authorization.js", + "package/dist/authorization.js.map", "package/dist/client.d.ts", "package/dist/client.d.ts.map", "package/dist/client.js", "package/dist/client.js.map", + "package/dist/controls.d.ts", + "package/dist/controls.d.ts.map", + "package/dist/controls.js", + "package/dist/controls.js.map", "package/dist/credentials.d.ts", "package/dist/credentials.d.ts.map", "package/dist/credentials.js", @@ -328,6 +336,10 @@ test("packed tarball carries dist and license only", () => { "package/dist/watch.d.ts.map", "package/dist/watch.js", "package/dist/watch.js.map", + "package/dist/wire-json.d.ts", + "package/dist/wire-json.d.ts.map", + "package/dist/wire-json.js", + "package/dist/wire-json.js.map", "package/package.json", ]; expect([...packedFiles.keys()].sort()).toEqual(expectedFiles); diff --git a/sdk/typescript/test/plan-resolution.test.ts b/sdk/typescript/test/plan-resolution.test.ts index d0e518c10f..a9a70eb250 100644 --- a/sdk/typescript/test/plan-resolution.test.ts +++ b/sdk/typescript/test/plan-resolution.test.ts @@ -6,6 +6,8 @@ import { connect, type Event, InvalidStateError, + PLAN_APPROVAL_TOOL, + PLAN_APPROVED_PROCEED_TEXT, PlanContinuationStartError, ProtocolError, } from "../src/index.js"; @@ -175,3 +177,10 @@ describe("streaming plan resolution", () => { await client.close(); }); }); + +describe("plan approval constants", () => { + it("are exported so clients never mirror the daemon's tool name or proceed text", () => { + expect(PLAN_APPROVAL_TOOL).toBe("PresentPlan"); + expect(PLAN_APPROVED_PROCEED_TEXT).toBe("Plan approved by operator. Proceed with execution."); + }); +}); diff --git a/sdk/typescript/test/rpc-catalog.test.ts b/sdk/typescript/test/rpc-catalog.test.ts index e704b7f6ee..77bafefe97 100644 --- a/sdk/typescript/test/rpc-catalog.test.ts +++ b/sdk/typescript/test/rpc-catalog.test.ts @@ -122,4 +122,19 @@ describe("RPC transport catalog", () => { "Missing HTTP control path field session_id", ); }); + + it("posts the MCP authorization controls with no request body", () => { + // The daemon's control handlers reject any body (HTTP 400), so the catalog + // must not let the transport post the `{}` an empty message would encode to. + for (const key of [ + "HarnessService.RecheckMcpAuthorization", + "HarnessService.CancelMcpAuthorization", + ] as const) { + const { http } = RPC_CATALOG[key]; + expect(http.kind).toBe("http"); + if (http.kind !== "http") throw new Error(`${key} is not an HTTP route`); + expect(http.method).toBe("POST"); + expect(http.requestBody).toBe("none"); + } + }); }); diff --git a/sdk/typescript/test/run-controls.test.ts b/sdk/typescript/test/run-controls.test.ts new file mode 100644 index 0000000000..3a45692118 --- /dev/null +++ b/sdk/typescript/test/run-controls.test.ts @@ -0,0 +1,243 @@ +import { createRouterTransport } from "@connectrpc/connect"; +import { describe, expect, it } from "vitest"; + +import { HarnessService } from "../src/gen/mecatl/v1/harness_pb.js"; +import { + type Client, + connect, + createHttpTransport, + imagePart, + ServerError, + UnsupportedFeatureError, +} from "../src/index.js"; + +const sessionId = "session-run-controls"; +const runId = "run-run-controls"; + +interface RecordedRequest { + readonly body: Record | undefined; + readonly method: string; + readonly path: string; +} + +interface HarnessOptions { + readonly features?: readonly string[]; + readonly respond?: ( + path: string, + body: Record | undefined, + ) => Response | undefined; + readonly sessionCapabilities?: { image: boolean; audio: boolean }; +} + +function stale(): Response { + return Response.json( + { + code: "stale_run_control", + detail: "run is no longer current", + status: 409, + title: "Conflict", + type: "https://mecatl.stacklok.com/problems/stale_run_control", + }, + { headers: { "content-type": "application/problem+json" }, status: 409 }, + ); +} + +function httpHarness(options: HarnessOptions = {}): { + readonly client: Client; + readonly requests: RecordedRequest[]; +} { + const requests: RecordedRequest[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + requests.push({ body, method, path: url.pathname }); + if (url.pathname === "/v1/compatibility") { + return Response.json({ + api_major: 1, + features: options.features ?? ["http_steer"], + }); + } + if (url.pathname === `/v1/sessions/${sessionId}` && method === "GET") { + return Response.json({ + session_capabilities: options.sessionCapabilities ?? { audio: false, image: true }, + session_id: sessionId, + }); + } + const scripted = options.respond?.(url.pathname, body); + if (scripted !== undefined) return scripted; + if (url.pathname === `/v1/sessions/${sessionId}/approve`) { + return new Response(null, { status: 204 }); + } + if (url.pathname === `/v1/sessions/${sessionId}/cancel`) { + return new Response(null, { status: 204 }); + } + if (url.pathname === `/v1/sessions/${sessionId}/steer`) { + return Response.json({ message_id: body?.message_id ?? "", outcome: "accepted" }); + } + if (url.pathname === `/v1/sessions/${sessionId}/cancel-steer`) { + return Response.json({ message_id: body?.message_id ?? "", outcome: "retracted" }); + } + return Response.json({ code: "not_found", detail: "not found" }, { status: 404 }); + }; + const transport = createHttpTransport({ baseUrl: "http://mecatl.test", fetch }); + return { client: connect({ transport, transportKind: "http" }), requests }; +} + +function controlRequests(requests: RecordedRequest[]): RecordedRequest[] { + return requests.filter( + (request) => + request.path !== "/v1/compatibility" && request.path !== `/v1/sessions/${sessionId}`, + ); +} + +describe("session.controls (HTTP)", () => { + it("resolves an ask with the strict approve body", async () => { + const { client, requests } = httpHarness(); + const session = await client.sessions.get(sessionId); + await session.controls(runId).resolveAsk("ask-1", "allow_always"); + expect(controlRequests(requests)).toEqual([ + { + body: { + allow: true, + ask_id: "ask-1", + expected_run_id: runId, + verdict: "allow_always", + }, + method: "POST", + path: `/v1/sessions/${sessionId}/approve`, + }, + ]); + await client.close(); + }); + + it("cancels the named run only", async () => { + const { client, requests } = httpHarness(); + const session = await client.sessions.get(sessionId); + await session.controls(runId).cancel(); + expect(controlRequests(requests)).toEqual([ + { + body: { expected_run_id: runId }, + method: "POST", + path: `/v1/sessions/${sessionId}/cancel`, + }, + ]); + await client.close(); + }); + + it("steers with text, media parts, and the caller's message id, returning the outcome", async () => { + const { client, requests } = httpHarness(); + const session = await client.sessions.get(sessionId); + const ack = await session + .controls(runId) + .steer( + [ + { kind: "text", text: "focus" }, + imagePart({ bytes: new Uint8Array([1, 2]), mimeType: "image/png" }), + ], + { + messageId: "m-7", + }, + ); + expect(ack).toEqual({ messageId: "m-7", outcome: "accepted", promoted: false, runId: "" }); + const [steer] = controlRequests(requests); + expect(steer?.path).toBe(`/v1/sessions/${sessionId}/steer`); + expect(steer?.body).toMatchObject({ + expected_run_id: runId, + message_id: "m-7", + text: "focus", + }); + expect(steer?.body?.parts).toEqual([{ data: "AQI=", kind: "image", mime_type: "image/png" }]); + await client.close(); + }); + + it("reports too_late verbatim so the caller keeps the text", async () => { + const { client } = httpHarness({ + respond: (path) => + path === `/v1/sessions/${sessionId}/steer` + ? Response.json({ outcome: "too_late", promoted: true, run_id: "run-next" }) + : undefined, + }); + const session = await client.sessions.get(sessionId); + await expect(session.controls(runId).steer("late")).resolves.toEqual({ + messageId: "", + outcome: "too_late", + promoted: true, + runId: "run-next", + }); + await client.close(); + }); + + it("retracts the pending steer bundle", async () => { + const { client, requests } = httpHarness(); + const session = await client.sessions.get(sessionId); + await expect(session.controls(runId).cancelSteer({ messageId: "m-9" })).resolves.toEqual({ + messageId: "m-9", + outcome: "retracted", + }); + expect(controlRequests(requests)).toEqual([ + { + body: { expected_run_id: runId, message_id: "m-9" }, + method: "POST", + path: `/v1/sessions/${sessionId}/cancel-steer`, + }, + ]); + await client.close(); + }); + + it("surfaces a stale control as the server's typed error", async () => { + const { client } = httpHarness({ + respond: (path) => (path === `/v1/sessions/${sessionId}/approve` ? stale() : undefined), + }); + const session = await client.sessions.get(sessionId); + const failure = session.controls(runId).resolveAsk("ask-1", "deny"); + await expect(failure).rejects.toBeInstanceOf(ServerError); + await expect(failure).rejects.toMatchObject({ code: "stale_run_control", status: 409 }); + await client.close(); + }); + + it("gates steer and cancel-steer on the http_steer feature", async () => { + const { client } = httpHarness({ features: [] }); + const session = await client.sessions.get(sessionId); + const controls = session.controls(runId); + await expect(controls.steer("x")).rejects.toMatchObject({ + code: "unsupported_feature", + feature: "http_steer", + }); + await expect(controls.cancelSteer()).rejects.toMatchObject({ + code: "unsupported_feature", + feature: "http_steer", + }); + await client.close(); + }); + + it("refuses an empty run id", async () => { + const { client } = httpHarness(); + const session = await client.sessions.get(sessionId); + expect(() => session.controls("")).toThrow(/run id/); + await client.close(); + }); +}); + +describe("session.controls (gRPC)", () => { + it("is a typed unsupported-feature error on a protobuf transport", async () => { + const transport = createRouterTransport((router) => { + router.service(HarnessService, { + getCompatibilityInfo: () => ({ apiMajor: 1, features: [] }), + getSession: () => ({ session: { sessionId } }), + }); + }); + const client = connect({ transport }); + const session = await client.sessions.get(sessionId); + const failure = session.controls(runId).cancel(); + await expect(failure).rejects.toBeInstanceOf(UnsupportedFeatureError); + await expect(failure).rejects.toMatchObject({ + feature: "prompt_free_controls", + transport: "grpc", + }); + await client.close(); + }); +}); diff --git a/sdk/typescript/test/wire-json.test.ts b/sdk/typescript/test/wire-json.test.ts new file mode 100644 index 0000000000..8caac57b19 --- /dev/null +++ b/sdk/typescript/test/wire-json.test.ts @@ -0,0 +1,83 @@ +import { fromJson } from "@bufbuild/protobuf"; +import { describe, expect, it } from "vitest"; + +import { + GetLearningProposalResponseSchema, + GetSessionResponseSchema, +} from "../src/gen/mecatl/v1/harness_pb.js"; +import { ListSchedulesResponseSchema } from "../src/gen/mecatl/v1/schedule_pb.js"; +import { normalizeWellKnownJson } from "../src/wire-json.js"; + +describe("normalizeWellKnownJson", () => { + it("rewrites stdlib-JSON Timestamps nested in messages, lists, and oneofs", () => { + const raw = { + proposal: { + created_at: { seconds: 1_700_000_000, nanos: 500_000_000 }, + decisions: [{ actor: "op", at: { seconds: 1_700_000_001 } }], + id: "p1", + updated_at: {}, + }, + }; + const normalized = normalizeWellKnownJson(GetLearningProposalResponseSchema, raw); + expect(normalized).toEqual({ + proposal: { + created_at: "2023-11-14T22:13:20.500Z", + decisions: [{ actor: "op", at: "2023-11-14T22:13:21Z" }], + id: "p1", + updated_at: "1970-01-01T00:00:00Z", + }, + }); + // The received value is never mutated. + expect(raw.proposal.created_at).toEqual({ seconds: 1_700_000_000, nanos: 500_000_000 }); + const message = fromJson(GetLearningProposalResponseSchema, normalized, { + ignoreUnknownFields: true, + }); + expect(message.proposal?.createdAt?.seconds).toBe(1_700_000_000n); + expect(message.proposal?.createdAt?.nanos).toBe(500_000_000); + expect(message.proposal?.decisions[0]?.at?.seconds).toBe(1_700_000_001n); + }); + + it("rewrites Durations and one-shot Timestamps in schedule specs", () => { + const normalized = normalizeWellKnownJson(ListSchedulesResponseSchema, { + schedules: [ + { + spec: { + fire_timeout: { seconds: 90, nanos: 250_000_000 }, + name: "nightly", + trigger: { one_shot: { seconds: "1700000000" } }, + }, + state: { next_fire_at: { seconds: 1_700_000_100, nanos: 0 } }, + }, + ], + }); + expect(normalized).toEqual({ + schedules: [ + { + spec: { + fire_timeout: "90.250s", + name: "nightly", + trigger: { one_shot: "2023-11-14T22:13:20Z" }, + }, + state: { next_fire_at: "2023-11-14T22:15:00Z" }, + }, + ], + }); + const message = fromJson(ListSchedulesResponseSchema, normalized, { + ignoreUnknownFields: true, + }); + expect(message.schedules[0]?.spec?.fireTimeout?.seconds).toBe(90n); + expect(message.schedules[0]?.spec?.fireTimeout?.nanos).toBe(250_000_000); + }); + + it("returns the same value when nothing needs rewriting", () => { + const raw = { + session: { session_id: "s1", state: "idle", updated_at: "2023-11-14T22:13:20Z" }, + }; + expect(normalizeWellKnownJson(GetSessionResponseSchema, raw)).toBe(raw); + }); + + it("leaves a Timestamp-shaped object with foreign keys alone", () => { + const raw = { session: { session_id: "s1", updated_at: { seconds: 1, other: true } } }; + expect(normalizeWellKnownJson(GetSessionResponseSchema, raw)).toBe(raw); + }); +}); diff --git a/studio/.env.example b/studio/.env.example new file mode 100644 index 0000000000..27848d1e13 --- /dev/null +++ b/studio/.env.example @@ -0,0 +1,129 @@ +# Mecatl Studio — environment variables +# Copy this file to .env.local and fill in the values. +# +# Studio has two pure deployment modes: +# +# MANAGED (default): `npm run dev` (or `task studio:dev`) starts the local +# controller, which supervises a `mecated` it spawns from ../bin/mecated on a +# random loopback port with a generated bearer token. Requires `task build` +# at the repo root first. No variables are needed. +# +# EXTERNAL: set MECATL_BASE_URL and Studio proxies to that daemon instead. +# No controller runs; every local control surface (provider, model router, +# MCP gateway) answers 409 as owned by the deployment. + +# ── External mode ──────────────────────────────────────────────────────────── +# Base URL of an already-running mecated HTTP listener. Its presence selects +# external mode. +# MECATL_BASE_URL=https://mecated.internal.example.com:8081 + +# Bearer token for the external daemon (`mecated serve --auth-token ...`). +# Injected server-side only; the browser never sees or supplies it. +# MECATL_AUTH_TOKEN= + +# Display-only: the workspace label the Settings page shows in external mode. +# Session placement is server-owned (ADR 0291) — the daemon binds every +# session to its own deployment; Studio never sends a workspace to it. +# MECATL_WORKSPACE=/absolute/path/to/workspace + +# ── External mode: remote OIDC sign-in (the `mecatui login` analogue) ──────── +# Explicit shape: the deployment's identity provider and public client. +# Settings → Model provider → "Remote sign-in" then offers Sign in (a popup) +# and "Copy sign-in link" (the --no-browser flow: open the link in any +# browser that can reach this Studio). Tokens stay in the Studio server. +# MECATL_OIDC_ISSUER=https://idp.example.com/realms/mecatl +# MECATL_OIDC_CLIENT_ID= +# MECATL_OIDC_AUDIENCE= +# MECATL_OIDC_SCOPE=openid profile email offline_access +# MECATL_OIDC_REDIRECT_URI=http://localhost:3000/api/auth/oidc/callback + +# Discovered shape (instead of ISSUER/CLIENT_ID): read the profile from the +# deployment's own RFC 9728 /.well-known/oauth-protected-resource. The card +# lists issuer / client id / audience / scopes and nothing signs in until you +# choose "Continue with browser login" (the TUI's review prompt). +# MECATL_OIDC_DISCOVERY=1 + +# Allow a plain-HTTP deployment or issuer on loopback / RFC 1918 addresses +# (`--private-issuer`). HTTPS is otherwise required for both. +# MECATL_OIDC_PRIVATE_ISSUER=1 + +# Seconds a started sign-in (or copied link) stays valid (`--callback-timeout`, +# default 600, clamped to 30..86400). +# MECATL_OIDC_CALLBACK_TIMEOUT=600 + +# Durable sign-in (`--credential-store file`): keep the tokens AES-256-GCM +# encrypted in .scratch/oidc-tokens.json so a Studio restart does not sign +# you out. The key is 32 random bytes, base64 (`openssl rand -base64 32`). +# Default: memory (a restart requires a fresh sign-in). +# MECATL_OIDC_TOKEN_STORE=file +# MECATL_OIDC_TOKEN_STORE_KEY= +# MECATL_OIDC_TOKEN_STORE_PATH=.scratch/oidc-tokens.json + +# Credential ordering. By default a configured OIDC sign-in decides and +# MECATL_AUTH_TOKEN is never a fallback. PREFER_STATIC=1 makes a non-empty +# static token outrank the sign-in (mecatui's ordering); ANONYMOUS=1 sends no +# credential at all (`--anonymous`). +# MECATL_AUTH_PREFER_STATIC= +# MECATL_AUTH_ANONYMOUS= + +# ── External mode: upstream TLS (`--tls-ca` / `--insecure`) ────────────────── +# A PEM bundle (file path, or the PEM text inline) trusted for the daemon and +# identity-provider connections — a private CA. The scheme itself is +# MECATL_BASE_URL's. +# MECATL_TLS_CA=/etc/mecatl/ca.pem +# Disable certificate verification for those connections (warned once on +# stderr and shown in Settings). Mutually exclusive with MECATL_TLS_CA. +# MECATL_TLS_INSECURE= + +# ── Origin pinning (both modes) ────────────────────────────────────────────── +# Comma-separated list of origins Studio itself is served from. Requests whose +# Host or Origin fall outside this list are refused (CSRF/DNS-rebinding gate). +# MECATL_STUDIO_PUBLIC_ORIGIN=http://localhost:3000,http://127.0.0.1:3000 + +# Origin allowlist enforced by the local controller (managed mode). +# MECATL_STUDIO_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# ── Managed mode options ───────────────────────────────────────────────────── +# Provider for the supervised daemon: mock, toolhive, or any provider named +# in auth.yaml (openrouter, anthropic, openai, opencode, …). When set it is +# authoritative for the controller's lifetime. When unset the controller +# starts from the provider last chosen in Settings → Model provider ("Set as +# active" / "Switch to offline mock", persisted in +# .scratch/daemon-defaults.json alongside the other daemon defaults — +# default/subagent model, reasoning effort, context window, prompt caching, +# base-URL overrides, ToolHive gateway, aliases/slots, credentials-file +# path — all passed to mecated as spawn flags), then probes for a local +# ToolHive LLM gateway, and otherwise falls back to the offline mock. +# Credentials are never entered in Studio: mecated reads them from +# ~/.config/mecatl/auth.yaml (or the .yaml chosen under Daemon defaults). +# MECATL_STUDIO_PROVIDER= + +# Set to 1 to allow an MCP gateway URL on loopback HTTP (HTTPS is otherwise +# required). +# MECATL_ALLOW_INSECURE_LOOPBACK_MCP= + +# ── Branding (optional) ────────────────────────────────────────────────────── +# Display name used as the logo's alt text. +# BRAND_NAME= + +# URL of a custom logo image, proxied server-side by /brand/logo. +# BRAND_LOGO_URL= + +# URL of a custom favicon (ICO, PNG, SVG, JPEG). +# FAVICON_URL= + +# Default colour palette for browsers that have not picked one under +# Settings → Personalize → Palette: default, aztec, mono or solar (the +# MECATUI_THEME analogue; light/dark still follows the Theme setting). An +# unknown value is ignored and the shipped Stacklok palette stands. +# BRAND_PALETTE= + +# Directory of custom palette files the deployment supplies (the mecatui +# --theme-dir analogue): each `*.json` is a `{"name", "label"?, "palette": +# {token: colour}, "dark"?: {token: colour}}` document, ≤ 8 KiB, at most 32 +# files, re-read on every request to /api/palettes so edits need no rebuild. +# Only allowlisted token names and #hex / rgb() / hsl() / oklch() / oklab() / +# color() values are accepted; a broken file is skipped with a server log +# line. They appear in Settings → Personalize → Palette as "· operator", +# read-only; users add their own under Custom palettes (stored per browser). +# STUDIO_PALETTE_DIR= diff --git a/studio/.gitignore b/studio/.gitignore new file mode 100644 index 0000000000..cb674bbf9f --- /dev/null +++ b/studio/.gitignore @@ -0,0 +1,60 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +# Playwright/Cucumber E2E artifacts +test-results/ +playwright-report/ +blob-report/ + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# helm +*.tgz +helm/charts/ +helm/*.lock + +# Claude Code local settings +.claude/settings.local.json + +# task studio:dev's background log +dev.log + +# local scratch + controller runtime state (ready file, router config) +/.scratch/ diff --git a/studio/.nvmrc b/studio/.nvmrc new file mode 100644 index 0000000000..85e502778f --- /dev/null +++ b/studio/.nvmrc @@ -0,0 +1 @@ +22.22.0 diff --git a/studio/CLAUDE.md b/studio/CLAUDE.md new file mode 100644 index 0000000000..d951b4fd66 --- /dev/null +++ b/studio/CLAUDE.md @@ -0,0 +1,662 @@ +# CLAUDE.md — Mecatl Studio + +The web client for the mecatl harness: a Next.js app (App Router) serving the +Atrium workspace — Chats · Scheduled · Skills · Memory · Settings — against a +`mecated` daemon. See ADR 0347 (module + posture) and ADR 0348 (server-backed +chats). + +## Commands + +Run through the root Taskfile, not bare npm: + +```sh +task build # repo root first — studio's managed mode spawns ../bin/mecated against the configured root (default: the repo; the controller's `POST /workspace` — no settings page since Sept 2026) +task studio:dev # start Studio + its mecated supervisor (background; logs in studio/dev.log) +task studio:stop # stop web server + controller + the mecated it supervises +task studio:test # vitest run + the hermetic server-tier suite (builds first) +task studio:lint # biome +task studio:typecheck +``` + +`npm run dev` = managed mode via `scripts/dev-local.mjs` (controller on :8788, +web on :3000). `npm run dev:web` = bare `next dev` (external mode or against an +already-running controller). Setting `MECATL_BASE_URL` selects external mode. +The hermetic suite (`npm run test:server`) builds Next first — a stale build is +the usual reason it fails mysteriously. + +**Non-technical audience (September 2026).** Studio is written for office +users. Removed from the UI, with the underlying controller documents and +defaults kept: the first-run welcome card, mascot and capability hints; the +sidebar's Chats / Runs / Scheduled / Drafts tabs (chats only list now; runs +and fires stay reachable through the read-only transcript dialog); the chat +header's placement badge and status strip (facts moved to ⋯ → Session +details); the top-nav connection and posture chips; the composer's Memory +pill and its context pill (the chat shows no context-window usage at all — +token figures live only in ⋯ → Token usage; the Agents panel's per-member +meter stays); the Skills page's empty-state hint about dropping a +`SKILL.md` into `.mecatl/skills` (the empty state is just "No skills here +yet"); and the Settings pages Status line, Tools, Persona, Keyboard +(rebinding), Model router and Workspace, plus the Preferences-file card, the +Custom-palettes card, the performance/pprof card, the posture card, the +store-location and retention cards and the configuration reference. The +status-line template library (`src/lib/statusline`) is gone with the strip. +Every settings card uses plain language ("the agent"), has no subtitle under +its title, and shows no restart sentence: single-switch cards (memory +stores, agent behaviour, usage statistics, the default model) apply on the +flip and the agent restarts in the background ("Applying…" while busy); +forms with several fields keep Save. The composer carries three pills — Mode, +Tools (All / None) and Safety (the operator posture, Strict/Trusted/Auto/Yolo, +same permissions document as Settings → Permissions) — plus Model, which +names the model the agent actually uses by default. The sidebar lists chats +only (no agent roster; @-mentions still work in the composer). + +Studio's own build stamp (Settings → Provider → About's `Studio` row, the +`/diagnostics` report's `client build:` line; `src/lib/studio-build.ts`) is +inlined at `next build` by `next.config.ts` (`NEXT_PUBLIC_STUDIO_BUILD` = +`MECATL_STUDIO_BUILD` when the build environment sets it, else +`+`, else `+dev` without a `.git`). +`next start` reports the stamp of the build it serves, not the running +checkout — a bug report wants the build. + +Settings → About (`settings/help/page.tsx`) is the web `--version`: Studio's +plain version (inlined as `NEXT_PUBLIC_STUDIO_VERSION`, +`src/lib/studio-version.ts`), the docs and support links, and a short +"About the agent" card. The configuration reference is no longer rendered, +but `src/lib/studio-config-reference.ts` stays the DATA (every env var the +server tier, the controller or the build reads; its test scans the source +for `process.env.X` reads, so a new knob must get a row), and +`GET /api/studio/about` (same-origin, `requestIsTrusted`) answers which +names are set as booleans — never a value. The ⌘K search lists the +help pages (`src/lib/workspace-pages.ts`) under a Pages group. + +## Module shape + +- `@stacklok-oss/mecatl-sdk` (source `../sdk/typescript`, a `file:` dependency + — `task studio:install` and CI build it before `npm ci`) — the ONLY reader + of the daemon wire. Studio consumes the daemon exclusively through the SDK's + HTTP transport pointed at the same-origin `/api/mecatl` proxy; the SDK owns + generated proto bindings, SSE run/watch decoding, and unknown-event forward + compatibility. `src/lib/harness/` adapts SDK values to Studio's view models + (`sdk.ts` builds the client). Controller calls are NOT in the SDK. +- `src/lib/server-proxy.ts` + `src/app/api/mecatl{,-control}/[...path]` — the + server tier: origin trust, header allowlists, bearer injection (auth ONLY — + daemon bodies and queries are forwarded verbatim), external-mode 409 policy. +- `scripts/local-controller.mjs` — managed-mode sidecar (supervises `mecated`, + owns model-router/MCP-gateway config + OAuth, the permissions document + — operator posture / project trust / shell-less mode — as mecated spawn + flags via `GET|POST /permissions`, the session-store location via + `POST /storage`, the retention policy via `POST /retention`, and the + daemon defaults — default/subagent model per provider, reasoning effort, + context window, prompt caching, base-URL overrides, ToolHive LLM gateway, + aliases/slots, credentials-file path, plus the durable active-provider + choice — as spawn flags via `GET|PUT /daemon-defaults`, and the runtime + settings — learning mode/sensitivity as a second CLI-tier + `--permission-config` file carrying the FULL merged `learning:` block, + the steer opt-out and the soul flags (`--no-steer`, `--no-soul`, + `--soul-strict`, `--soul-file`, the one-shot `--approve-soul`) as spawn + flags — via `GET|PUT /runtime-settings` + `POST /soul/approve`); policy + helpers in `src/lib/controller-security.mjs`, the flag grammars in + `src/lib/controller-permissions.mjs`, `src/lib/daemon-defaults.mjs` and + `src/lib/runtime-settings.mjs`; the controller's OWN project-trust + registry probes — authority + identity anchor mirroring the daemon's + `workspacetrust` — in the node-only `src/lib/controller-trust.mjs` + (`/status.trust`, `POST /permissions/trust`, `POST /permissions/trust-once`). +- `src/features/agent/` — runtime-status provider + the daemon-backed hooks; + `src/app/workspace/**` — the five surfaces. + +## Rules that have teeth + +Each rule is backed by a test; break the rule and its test names you. + +1. **Daemon-only: an unreachable daemon renders offline, never demo data.** + There are no fixtures to fall back to — do not add any. The one sanctioned + exception is the explicit, default-off, clearly-labeled Labs mock content + (Settings → Labs → "Show demo chat", `src/features/agent/mock-tour.ts`) + — an opt-in demo the user turns on, never a fallback for an unreachable + daemon. Its sibling is Labs → "Developer tools" (mecatui's client debug + mode): the `/debug-ask` built-in / "Inject fake approval" menu item park + a clearly-labeled SYNTHETIC permission ask (`src/features/agent/debug-ask.ts`, + `ApprovalRequest.synthetic`) that never reaches the daemon — its verdict + is short-circuited, and a genuine ask displaces it — and the queue strip + shows the steer correlation trace (`src/features/agent/steer-trace.ts`). + (`tests/rendered-html.test.mjs`: unreachable daemon → friendly 503; + `use-agent-chat.debug-ask.test.ts`: no request on a synthetic verdict.) +2. **Session placement is server-owned; Studio never sends a workspace.** + The daemon binds every session to its deployment's environment (ADR 0291) + and decodes `POST /v1/sessions` with unknown fields disallowed, so the + proxy forwards create bodies verbatim — no `workspace` injection into + session/team/schedule creation, no `?workspace=` on `/v1/commands` (it is + keyed by `session_id`). `MECATL_WORKSPACE` (external) and the controller's + `/status` `workspace` are display-only labels in the browser: the latter + is the spawn root the MANAGED controller owns (the TUI's `--workspace`), + changeable ONLY through the controller's header-gated `POST /workspace` + (the Settings → Workspace page was removed; a path is operator + configuration) — validated on the controller's OWN filesystem + (`src/lib/workspace-config.mjs`: absolute, exists, a directory, not the + state dir), persisted in `studio-workspace.json`, a restart; a non-default + root gets its OWN session store and memory dir keyed by a path hash UNDER + the default root's `.scratch` (chats belong to their root and reappear + when you switch back; a foreign repo gains only `.mecatl/skills`), and a + change withdraws the remembered/once project-trust grants. `/status` also + carries `defaultWorkspace`; the About card shows a `Workspace` row in + both modes. It is never sent to the daemon as a placement. + (`workspace-config.test.ts`, `client-status.test.ts`, + `about-daemon-card.test.tsx`, hermetic 409 + header-gate rows.) The one placement CHOICE + Studio offers — "Switch worktree…" in the chat menu + (`worktree-picker-dialog.tsx`) — travels as the daemon's OPAQUE + `worktree_selector` from `GET /v1/worktrees?session_id=`, on ClearSession + ("Start fresh there") or ForkSession ("Bring this conversation"), never a + path; a stale selector (412 `placement_selector_*`) relists instead of + erroring. Gated on `serverCapabilities.worktrees` AND the row's `fork` + verdict; never offered on the draft, the mock tour or an AI-debug chat. + (hermetic: session creation is forwarded verbatim; `worktrees.test.ts`, + `create-body.test.ts`, `worktree-picker-dialog.test.tsx`, the Playwright + worktree test.) The chat header SHOWS the placement the daemon bound — + the snapshot's `placement` display metadata (label · branch, kind and + short revision on hover; "No filesystem" for a no-fs session) as + `placement-badge.tsx`, the TUI's startup placement line; never a path, + never a selector, hidden when an older daemon omits it + (`placement-badge.test.tsx`, `client-actions.test.ts`). +3. **Credentials never cross the browser/controller boundary.** No key-paste + UI anywhere; `mecated` reads `~/.config/mecatl/auth.yaml`. The proxy's + header allowlist excludes `authorization` from the browser. + (hermetic: bearer injected server-side.) +4. **Controller mutations are server-only.** They require the server-set + `x-mecatl-studio-request` header, a loopback Host, and an allowlisted + Origin. (hermetic: CSRF/DNS-rebinding truth table.) +5. **External mode owns nothing locally.** Every control write answers 409. + (hermetic: control writes 409.) +6. **A failed turn renders as failed.** `result.stop === "error"` with no text + must never become a quiet success — the SDK's terminal `result` event + always reaches the UI. +7. **Unknown event kinds are surfaced, never dropped.** The SDK decodes a new + daemon event kind as a typed unknown; Studio shows it as "not rendered + yet". +8. **The memory panel is read-only.** A value typed into the UI would land in + turn-0 context bypassing injection scanning; the daemon has no write API by + design. Do not add an editor. +9. **Session rows obey the store.** Eligibility comes from row capabilities + (omitted = denied); a row is removed only by a complete inventory walk or a + 404; renames adopt the daemon's clamped echo. + (`src/lib/protocol/sessions.test.ts` + `use-agent-sessions`.) +10. **Schedule PUT replaces the whole spec.** Fields the form cannot edit ride + the row's `carried` spec and are re-encoded, or they are silently deleted. + (`src/lib/protocol/schedules.test.ts`: carried round-trip.) +11. **Requests are protojson; responses are stdlib JSON.** Never echo a decoded + response back as a request body. (`schedules.test.ts`: the asymmetry test.) +12. **Skills are project-scoped by default, and the controller owns the + directory.** The controller passes ONE `--skills-dir` — the workspace's + `.mecatl/skills` unless the daemon-options document relocates it (rule + 24: a trust decision, confined to the workspace / default root / mecatl + config dir) — and never `--skills-conventional`. With the Skill tool + switched off it passes NO `--skills-dir` (that is how mecated drops the + tool); the Skills page still edits and parks skills in the owned + directory, and shows the daemon's `skills: false` as a banner. +13. **Posture, project trust and shell-less mode are spawn FLAGS, never a + settings.yaml key.** Settings → Permissions saves a Studio-owned + `permissions.json`; the controller turns it into `--posture ` + (ALWAYS passed, strict included, so Studio's tier out-ranks an imported + operator settings file's `posture:` in both directions), + `--trust-project` and `--no-shell`, and never `--headless` (mecated + raises the trust floor for trusted+ on interactive roots — the page's + "implied by the posture" claim depends on it). The daemon-wide posture is + NOT the composer's per-session Mode (the Mode menu's "Safety level" + rows write the SAME permissions document — one writer, two entry + points); the EFFECTIVE tier is + `serverCapabilities.posture` (capability-gated: absent on an older + daemon). External mode: `permissions: null`, every `/permissions` verb + 409. The four tiers are the SDK's `ServerPosture` ladder and nothing + else — every Studio copy (`POSTURES`, `POSTURE_TIERS`, the picker's + `POSTURE_OPTIONS`) is pinned to it, in order. + (`src/lib/controller-permissions.test.ts`, + `permissions-section.test.tsx`, `posture-vocabulary.test.ts`, hermetic + 409 + CSRF rows, the Playwright external-mode Permissions test.) Project + trust itself is the controller's OWN registry, never the daemon's: + mecated never prompts, and Studio cannot reach the Go composition layer + mecatui's pre-TUI prompt uses, so `src/lib/controller-trust.mjs` + mirrors the daemon's two probes in JavaScript — `hasProjectAuthority` + (authority.go: soul / any file under the six anchor dirs / a non-empty + `permissions.allow` in `.mecatl/settings{,.local}.yaml` or + `.claude/settings{,.local}.json`) and `trustAnchor` (anchor.go's + transcript byte-for-byte; permission rules deliberately NOT folded) — + re-read ONCE PER SPAWN in `startMecatl`, never per `/status` poll. A + saved grant carries the anchor it was granted at; a spawn whose live + anchor differs is DRIFTED and gets NO `--trust-project` (the daemon's + own fail-safe arm) until the bodyless `POST /permissions/trust` + re-accepts it; `POST /permissions/trust-once` is mecatui's "trust once" + — in-memory, dies with the controller, rides every restart in between. + Both grants are studio-header-gated and 409 in external mode, and reach + the UI only through `useHarnessRuntime().trustProject` / + `.trustProjectOnce` (busy label `trust`), which re-read `/status.trust` + so the page shows the decision the NEW spawn got. The registry never + reads or writes `trust.yaml` / `trustedWorkspaces:` — the daemon may + trust a workspace Studio reads as untrusted, and the UI says so. + The PROMPT itself is mecatui's pre-TUI "trust / trust once / no" as a + workspace banner (`workspace-trust-banner.tsx`, mounted in the layout's + banner band): it renders only when the controller reports admittable + authority AND the spawn got no grant (`untrusted`, or a `drifted` + remembered grant), never in external mode, never once the daemon's own + `GET /v1/soul` reports a TRUSTED project soul (the one wire signal that + the daemon admitted the project from a registry Studio cannot see), and + not after "Not now" — which stores the LIVE anchor under + `localStorage["mecatl.trust.dismissed:"]`, so a changed + authority set (a new anchor) re-prompts. Both grants confirm first + (the daemon restarts; in-flight runs end) and the once-copy says the + grant lasts until the CONTROLLER restarts. Settings → Permissions shows + the decision as a "Project trust" row with "Forget trust" (the switch + off) and, on drift, "Trust again" (the explicit grant — a plain save + with the switch already on never re-stamps the anchor). + (`src/lib/controller-trust.test.ts`, `use-harness-runtime.test.ts`, + `harness/trust.test.ts`, `workspace-trust-banner.test.tsx`, + `permissions-section.test.tsx`, hermetic 409 + CSRF rows for both grant + routes and the external `trust: null`.) +14. **The session store is a spawn FLAG too, never a settings.yaml key, and + in-memory means NO flag.** Settings → Storage saves a Studio-owned + `storage-settings.json` (seeded from `MECATL_STUDIO_STORE_DIR` / + `MECATL_STUDIO_NO_STORE=1`); the controller turns a durable store into + `--store-dir ` right after `--workspace` (relative paths resolve + under the controller's workspace, the dir is created first) and an + in-memory store into the ABSENCE of the flag (mecated has no `--no-store`). + `/status.storage` reports the saved AND default location; external mode: + `storage: null`, `POST /storage` 409. There is NO settings UI for the + location any more (operator configuration; the route and document stay). + (`src/lib/storage-settings.test.ts`, `store-location.test.ts`, hermetic + 409 + CSRF rows.) +15. **Retention is spawn FLAGS too, and main-chat deletion needs the + acknowledgement — in Studio AND in mecated.** The controller's + `POST /retention` saves a Studio-owned `retention.json` (no settings UI + since Sept 2026 — the write side stays for operators); the controller turns + each SET field into its mecated flag (`--main-retention`, + `--child-retention`, `--schedule-fire-retention`, their count caps, + `--child-gc-interval` for the sweep cadence, `--acknowledge-main-retention`) + and a null field into NO flag (mecated's default stands). Durations are + Go grammar — no `d`. A non-zero main age/count without + `acknowledgeMainDeletion: true` is refused by the controller (400) before + the restart mecated would fail; child/scheduled limits need no + acknowledgement. No flags at all while an imported operator settings + file is active (`managedBy: "operator-settings"`, `POST /retention` 400). + The EFFECTIVE policy the card shows is the daemon's own + `GET /v1/storage/health` (`policy`, `last/next_sweep_unix`), never the + saved document. External mode: `retention: null`, `POST /retention` 409. + (`src/lib/retention-settings.test.ts`, `harness/retention.test.ts`, + `harness/storage.test.ts`, hermetic 409 + CSRF rows.) +16. **Daemon defaults are spawn FLAGS, keyed per provider, and the active + provider is durable.** Settings → Model provider → "Daemon defaults" (and + the provider page's "Make daemon default" kebab, the Add dialog's "Save + override") save a Studio-owned `daemon-defaults.json`; the controller + turns it into `--default-model`/`--subagent-model` (the pair saved for + the SPAWN kind only — mecated validates them against the current default + provider fail-fast, so a stale pair must never ride a provider switch; + never for `--mock`), `--reasoning-effort`, `--context-window-override`, + the two LLM stream bounds `--llm-per-attempt-timeout` / + `--llm-stream-idle-timeout` (Go durations, `"600s"`; emitted ONLY when + they differ from mecated's own 300 s / 180 s — 0 is a real "disabled", + never "unset"; `LLM_TIMEOUT_DEFAULTS` in `daemon-defaults.mjs`), + `--no-prompt-cache`, `--anthropic-cache-ttl`, `---base-url`, + `--toolhive-llm=false`/`--toolhive-llm-base-url`/`--toolhive-llm-mode`, + repeated `--model-alias`/`--model-slot` (the `router` slot is reserved + for model routing, whose settings page was removed), and `--api-key-file` (a `.yaml` INSIDE the mecatl + config dir — the controller reads and rewrites that file for the + provider routes, so the path is confined). A refused start rolls the + previous document back and returns mecated's own refusal. `POST + /providers/active` persists the choice (`MECATL_STUDIO_PROVIDER` still + wins at boot; a saved kind that is no longer selectable is dropped, and + `DELETE /providers/{name}?scope=all` clears it); "Switch to offline + mock" is the explicit `--mock`. `GET /daemon-defaults` is header-free + read-only like `/status`; the PUT is not. External mode: + `daemonDefaults: null`, both verbs 409. (`src/lib/daemon-defaults.test.ts`, + `controller-security.test.ts`, `harness/daemon-defaults.test.ts`, + `use-daemon-defaults.test.ts`, `daemon-defaults-card.test.tsx`, + `provider-section.test.tsx`, hermetic 409 rows.) Provider REMOVAL has + the TUI's two scopes, from each row's kebab: `?scope=credential` + (`providers logout`, "Remove key (keep provider)") cuts ONLY the + `api_key` line from the provider's auth.yaml block — an emptied entry + becomes `name: {}` exactly as mecated's authfile writes it, and the + block plus a custom provider's definition stay, so it lists as + "configured, no key"; `?scope=all` (`providers remove`, the default) + cuts the whole auth.yaml block AND a custom provider's settings.yaml + definition, its saved model pair and the active choice. A custom + provider's NON-secret DEFINITION (`providers add`: id, `api_flavor`, + HTTPS `base_url`, `default_model`, `auth.method` `api_key`|`none`) is + the one thing Studio WRITES into the user-global settings.yaml: `POST + /providers/custom` (Add provider → Custom gateway → "Save definition"; + a keyless provider restarts at once, an `api_key` one waits for the + hand-pasted key — rule 3 holds: there is no key field and none is + accepted). Both the write and `?scope=all` on a name the imported + operator settings file defines are refused 409 BEFORE anything is + written (Studio never edits that file) and the UI withholds the buttons + while it is active. Every edit is a conservative line-range rewrite + (`provider-auth.mjs`), temp-file + rename, the file's existing mode + preserved (0600 when created). (`provider-auth.test.ts`, + `add-provider-dialog.test.tsx`, `provider-section.test.tsx`, + `use-provider-management.test.ts`, hermetic 409 + header-gate rows.) +17. **Diagnostics options are spawn FLAGS with ONE writer per flag, and the + posture is not one of them.** Settings → Diagnostics reads the + daemon-REPORTED `serverCapabilities.posture` and spells out the four + defenses that tier switches on (`src/lib/posture.ts` mirrors mecatui's + `/posture` sentence byte-for-byte); changing the tier stays the + Permissions page's job (`--posture` has exactly one writer). The + controller's DIAGNOSTICS document (`diagnostics-options.json`, `GET|POST + /diagnostics-options`, a PARTIAL patch body, both verbs behind the studio + header) becomes `--log-level`, an EXPLICIT `--metrics-addr` in both + directions (`--metrics-addr=` while the admin listener is off — mecated's + default is a fixed loopback port two managed daemons would fight over), + `--perf-mcp` only with a real listener address, `--goroutine-warn- + threshold`, `--product-metrics=false`/`--product-metrics-dry-run` (never + `=true`: the controller's env opt-out — `DO_NOT_TRACK`, + `MECATL_PRODUCT_METRICS` — stays the operator's and is reported as + `productMetrics.source: "environment"`; `product-metrics-card.tsx` shows + the effective verdict as "Not disabled"/"Off" — never a definitive On, + because `telemetry.productMetrics.enabled` in settings.yaml is a third + opt-out Studio cannot read — and disables both switches under an env + opt-out); `quiet` only gates the + controller's stderr mirror and restarts nothing. The controller is + also the daemon's LOG FILE (mecatui's embedded-server log, ported): + mecated has no log-file flag, so the process holding its stderr appends + it to `studio/.scratch/mecated.log` (0600, ONE rotated generation + `mecated.log.1` at mecatui's 10 MiB bound, `src/lib/controller-log.mjs`) + and a bounded in-memory ring; `GET /logs?lines=` serves the tail + file + facts + `startupError`, `GET /logs/download` streams the file (both + studio-header-gated, 409 in external mode). The content is + model-influenced, so `daemon-log-card.tsx` renders it as plain text + only. Names are deliberately + distinct from the (separate) daemon-options document: + `diagnosticsOptions`, `diagnosticsOptionArgs`. External mode: both verbs + 409. (`src/lib/controller-diagnostics-options.test.ts`, + `src/lib/posture.test.ts`, `harness/diagnostics.test.ts`, + `use-diagnostics-options.test.ts`, `product-metrics-card.test.tsx`, + hermetic 409 rows, the Playwright diagnostics test.) Settings → + Diagnostics (under Support) now shows only the log tail / download, the + metrics opt-out and Restart; the posture card (see Permissions) and the + performance card were removed. The admin surface itself + reaches the browser ONLY through the controller: it probes a free + loopback port per spawn (the ready file names only `http_address`; one + re-probe + re-spawn when the failed start says address-in-use — + `src/lib/controller-perf.mjs`), `GET /perf` reports the LIVE origin, + paths and knobs (`/status.perf` mirrors it), and `GET /perf/metrics` / + `GET /perf/vars` relay the two TEXT endpoints (409 while off, 503 while + no child runs; `/debug/pprof` and `/debug/flightrecorder` are NEVER + relayed). All three are studio-header-gated and 409 in external mode. + No UI renders them any more (the performance card and + `prometheus-text.ts` were removed); the controller routes stay for + operators. Passing `--metrics-addr=` when the switch is off means a + managed daemon no longer opens mecated's default 127.0.0.1:9090 listener + — that is deliberate. (`controller-perf.test.ts`, hermetic 409 + + header-gate rows.) +18. **Storage maintenance is DAEMON-owned, capability-gated, and the + destructive step is typed-confirmed.** Settings → Storage is ONE card + that talks to mecated ONLY through the SDK's `client.storage` namespace + (`src/lib/harness/storage.ts` — no controller document, no spawn flag, + nothing to roll back): a plain health summary (`GET /v1/storage/health`, + gated on `serverCapabilities.storage_health`) and "Clean up old runs" + (`/v1/storage/cleanup:plan` → `cleanup:apply` → + `/v1/storage/cleanup/jobs/*`, gated on `storage_cleanup`); the Optimize + storage (migration) UI was removed — its fetchers stay in `storage.ts` + for operators. An ungated block renders nothing. A running job is polled every + `JOB_POLL_INTERVAL_MS` (2 s) by `useStorageMaintenance`, stopped on a + terminal state and on unmount, and health is re-read when it settles. + Clean-up ticks every kind BUT `main` by default, applies with the PLAN's + `confirmationToken` (never a client-minted one) behind `useTypedConfirm`, + whose confirm enables only when the field equals `CLEAN UP` exactly, and + a finished job with `deleted > 0` fires `notifySessionsChanged` so the + sidebar re-walks now. Stable codes are framed in ONE place + (`storage-maintenance-shared.tsx`): `cleanup_plan_stale` → "plan again" + with a Re-plan button, `migration_conflict` → the job already running → + Refresh health, `management_unauthorized` → read-only copy in either + card; an unknown code shows the daemon's own message. + (`harness/storage.test.ts`, `use-storage-maintenance.test.ts`, + `use-typed-confirm.test.tsx`, the three `storage-*-card.test.tsx`, + `sessions-changed.test.ts`, the Playwright storage test.) +19. **The session facts live in the Session details dialog, and never show + an address.** ⋯ → Session details (`session-details-dialog.tsx`) carries + the session id, resolved model, permission mode, safety level (the + daemon-reported posture) and server (managed by Studio / external + deployment, never a host). `chat-status-strip.tsx` renders ONLY for an + AI-debug session (the amber DEBUG target + privacy line); an ordinary + chat has no strip, so the conversation keeps its height. The strip's + rules below still hold where it renders. The handle is the documented BARE + 12-column literal (`src/lib/protocol/session-handle.ts` mirrors + `client.SessionHandle` byte for byte; no `#`); a click copies the full + id. The model is the snapshot's RESOLVED model (display name from the + picker list, `/route` from `provider.route` — translated to + `provider_route`, no longer silent — and the effective effort); + "resolving model…" shows only while the detail read is pending on a + live chat or the connection is connecting, and settles to the + configured id or "model unavailable" (`use-agent-chat`'s + `sessionDetailStatus`). A mode change made mid-run is HELD by + `useSessionMode({ busy })` as `pendingMode` — the strip reads + "(pending)", the pill stays enabled (`modeSwitchDeferred`) — and lands + once the run ends; a run parked on an approval is still busy. The + server segment is managed/external + deployment label only: the + same-origin proxy hides the daemon URL (rule 3), so no host is ever + rendered. On an AI-debug chat the strip turns amber with `DEBUG target + ` and the TUI's durable PRIVACY line (+ bound reporting + servers), and chat-workspace withholds mode/model/effort/compact so no + control can fork or rebind the binding. (`session-handle.test.ts`, + `chat-status-strip.test.tsx`, `use-session-mode.test.ts`, + `events.test.ts` provider.route, the Playwright status-strip test.) +20. **Workspace-services enrollment never keeps the consent URL, and opens + its window on the click.** Where the daemon advertises + `workspace_enrollment`, the TUI's "workspace services not connected" + notice (`workspace-enrollment-notice.tsx`) sits above the live chat's + composer with /tools-connect and /tools-cancel as buttons. Connect calls + `window.open` SYNCHRONOUSLY on the click, BEFORE any await (popup-blocker + safe), POSTs the BODYLESS connect (retry/cancel likewise — the daemon + 400s any body byte), points the window at the daemon's + `presentation_url`, and observes every 3 s until the enrollment settles; + that URL lives only in a ref while the enrollment is pending — never in + React state, the hook's return value, the DOM or a log + (`use-workspace-enrollment.test.tsx` serialises the state and asserts + its absence). A stale id (412) on retry falls back to a fresh connect; + cancel sends the exact id. The connector inventory + (`mcp_connector_status`, read once per open) speaks a DIFFERENT + vocabulary from the enrollment status — `completed` there is the + connected state — and a 401/403/404/412 folds to "unknown" (notice + shown) rather than an error. Hidden for a debug-target chat, the mock + tour and the draft; dismissible per session. (`enrollment.test.ts`, + `use-workspace-enrollment.test.tsx`, + `workspace-enrollment-notice.test.tsx`, the Playwright enrollment test.) +21. **A refused credential is never "unreachable".** The liveness probe is + typed (`HarnessStatus.status` / `.code`); `toHarnessError` reads the raw + problem body off the SDK error's `cause` (the SDK collapses every 401 + into "Authentication failed" and narrows unregistered codes to + "unknown"); and `offline-cause.ts` names the class: the proxy's 401 + `oidc_login_required` / `oidc_session_expired` (Studio refuses to dial + the deployment anonymously), its 502 `oidc_idp_unavailable`, and a + daemon 401/403 ("Credential rejected", with the MECATL_AUTH_TOKEN / + audience / issuer remedy). Those render `auth-recovery-banner.tsx` at the + point of failure — cause, remedy, Sign in / Sign in again (only when + `/api/auth/oidc/status` reports `configured`; the popup opens on the + click), "Open sign-in settings", Retry — and re-probe on the callback + page's `mecatl-oidc` message and on window focus, so the connected flip + (and `use-agent-chat`'s transcript rehydrate, the resume of the open + chat) never waits for the 5 s poll. Managed mode has no upstream sign-in: + its daemon 401 (the controller's own token refused) gets "Restart daemon" + and the controller-token remedy instead of a sign-in link to a provider + page with no sign-in card; an `oidc_*` code proves the proxy is external + and keeps the sign-in actions whatever `mode` says. A chat-level 401 puts + the same title + remedy on the error strip and re-probes at once. + Everything else keeps + "Mecatl is unreachable." There is ONE deployment (MECATL_BASE_URL): no + saved-target list, and the daemon URL is never rendered (rule 3). + (`offline-cause.test.ts`, `sdk-auth-errors.test.ts`, + `auth-recovery-banner.test.tsx`, `runtime-status.auth.test.tsx`, + `use-agent-chat.auth-failure.test.ts`, hermetic 401 relay row.) +22. **The top nav carries no status chips.** The former connection + indicator and posture badge were removed for a non-technical audience: + an unhealthy connection is the banners' job (offline / auth-recovery / + live-feed reconnecting), the daemon's posture is read in Settings → + Permissions, Settings → Diagnostics and ⋯ → Session details, and + changed from Settings → Permissions or the composer's Mode menu + ("Safety level", `usePostureControl` in `chat-input.tsx`, the same + `saveHarnessPermissions` write with the same Auto/Yolo confirmation). +23. **The `?prompt=` deep link never sends without a click.** The chat + route's arrival prompt (`lib/chat-seed.ts` → `use-seed-prompt.tsx`, the + web analogue of `mecatui -p`; the PWA share target lands there too) + pre-fills the composer. `&send=1` only puts the exact text behind the + `SeedPromptDialog` confirmation, because a URL is drive-by reachable and + under posture auto/yolo a prompt is tool execution in the user's + workspace; a same-origin referrer proves nothing (a link in a chat + message is same-origin). A leading-`/` prompt is a command and stays + prefill-only whatever `send` says; control characters other than newline + and tab are dropped; the query is stripped with a native replaceState on + the CURRENT path once consumed, so a reload or Back never re-seeds. + (`chat-seed.test.ts`, `use-seed-prompt.test.tsx`, + `seed-prompt-dialog.test.tsx`, the Playwright `?prompt=` tests.) +24. **Daemon options are controller-owned spawn FLAGS; the browser never + composes a mecated flag.** The Studio-owned `daemon-options.json` + (`GET|PUT /daemon-options`, `src/lib/daemon-options.mjs`) carries the + knobs mecatui documents for the tool catalog and the two memory stores: + project memory (`--memory-dir`; OFF = the flag omitted, which is how + mecated turns Remember/Recall off — never a `--no-memory`), the user + model (`--no-user-model`, `--user-model-dir`, + `--user-model-review-interval`, emitted only when ≠ 1), skill discovery + (`--skills-dir`; OFF = omitted, rule 12), slash commands + (`--commands-dir` when a dir is set, else `--enable-commands`; OFF = + nothing, mecated's default) and MCP discovery (`--toolhive=false`, + `--toolhive-group` only while discovery is on, `--mcp-resource-tools=false`, + `--mcp-prompts=false`). The DEFAULT document is the pre-feature command + line byte for byte. Knobs with another owner are NOT here — `--no-shell` + is the permissions document's (rule 13), learning/steer/soul are the + runtime settings' — one writer per flag. Every set directory is a TRUST + BOUNDARY (mecated's flag help: a SKILL.md or command template steers + the model like AGENTS.md) and is confined at save time to the live + workspace, the default root or the mecatl config dir, lexically AND + after realpath of its deepest existing ancestor; an existing path must + be a real directory. Only an ENABLED store's directory is created + before the spawn. A refused start rolls the previous document back. + `/status.skills` / `.memory` gain `enabled` and a `scope` computed from + the resolved path (`project` / `user` / `studio` / `other`), never a + hard-coded "project". Both verbs are studio-header-gated (the GET names + directories on this machine; the proxy adds the header) and 409 in + external mode. The only UI left over this document is Settings → + Memory's two switches (`memory-stores-card.tsx`: project memory, facts + about you — the `capabilities.memory`/`user_model` rows); the former + Tools page (skills directory, slash-command templates) and the MCP + discovery flags card were removed as operator configuration, and the + shared `daemon-options-card.tsx` scaffold went with them. The document + keeps every other field at its default. What the UI shows as ON is + always the daemon's capability document, never the saved document, and + `useDaemonOptions().save` re-probes it after the restart. + (`src/lib/daemon-options.test.ts`, `harness/daemon-options.test.ts`, + `use-daemon-options.test.ts`, `memory-stores-card.test.tsx`, + `skill-tool-disabled-banner.test.tsx`, `controller-security.test.ts`, + hermetic 409 + header-gate rows.) + +## Gotchas + +- `npm test` runs vitest in watch mode; CI and `task studio:test` use + `npx vitest run` + `npm run test:server`. +- The controller restarts `mecated` on every config write; in-flight runs and + session ids die with it. Surfaces warn before writes that restart. Startup + rides the daemon's ready file (`--ready-file` + an ephemeral `--http-addr` + + a mkfifo lifetime pipe — Node's stdio "pipe" is a socketpair mecated + rejects); `/status` reports the ready doc's `apiMajor`/`features`/ + `deployment`. +- `package-lock.json` cannot be regenerated from scratch while the + `file:../sdk/typescript` link is present: both npm 10 and npm 11 crash in + arborist (`Cannot read properties of null (reading 'edgesOut')`) walking the + SDK's pnpm-managed `node_modules`. Keep the committed lock and install + INCREMENTALLY (`npx -y npm@10.9.4 install`); bump a transitive family that + peer-pins itself to one exact version (tiptap) through `overrides`, never by + deleting the lock. `npm audit fix` hits the same crash — bump by hand. +- FireNow (`POST /v1/schedules/{name}/fire`) is synchronous — the request lasts + the whole agent run. +- Live re-attach to a running session rides the durable watch + (`GET /v1/sessions/{id}/watch`, ADR 0250; gate on the + `watch_session_events` feature): SSE `{event, cursor, phase}` envelopes — + replay from the cursor (empty = the beginning), one event-less + `phase: "live"` boundary frame, then live follow. The SDK's + `session.attach()`/`activity()` own the envelope decoding; `use-agent-chat` + attaches when the inventory reads running/awaiting and Studio is not itself + driving the run. + Residual: `POST /prompt` still cancels its run on client disconnect, so a + reload of the DRIVING tab still ends the run — the watch covers runs + driven elsewhere (schedules, other tabs/clients) and parked approvals. + An OPEN chat with no run to render ALSO holds a METADATA watch (mecatui's + `armLiveFeed`): the daemon has no "from now" for a session with no run, + so it replays from the beginning (or the last cursor this tab saw) and + SKIMS — nothing rendered, `session.title` forwarded in either phase, a + LIVE run-bearing frame this tab is not driving re-walks the inventory + once per run id so the row flips to running and the full watch takes + over. `session.title` translates to the `title` StreamEvent (never a + bubble, never "not rendered yet") and `useAgentSessions().applyTitle` + adopts it onto the row behind the title lifecycle REVISION (strictly + newer wins; unknown on either side adopts) — header, sidebar and tab + title update live. The SDK's `client.status` store is mirrored by + `use-connection-status.ts` into `connection-status-banner.tsx` (layout + band): `reconnecting` = amber "Live feed reconnecting…" (the state, not + a counter — the SDK's per-watch `AttachOptions.onReconnect` exists, but + one number over several watches would mislead), `unauthorized` = + destructive strip that re-probes the runtime ONCE so the auth-recovery + banner takes over, quiet whenever the runtime banner already owns the + band. (`events.test.ts` session.title, `session-title.test.ts`, + `use-agent-sessions.title.test.ts`, `use-agent-chat.title.test.ts`, + `use-connection-status.test.ts`, `connection-status-banner.test.tsx`.) +- A tool call parked on an MCP browser sign-in (`authorization.required`) + ENDS the prompt stream without a result; only the authorization controls + move the run again (`GET …/mcp-authorizations/{id}/presentation`, and the + BODYLESS `POST …/recheck` / `…/cancel` SSE relays — the daemon 400s any + body byte; the SDK posts no body on those routes). The + takeover card (`authorization-panel.tsx`) fetches the URL only when opened + or copied — it never rides an event — and after an open/copy + `use-agent-chat` re-checks every 3 s (a 10 s first-event bound per + control, one control stream at a time so exactly one adopts the + continuation run; polling stops when the request resolves from any + stream, on cancel, chat switch, or unmount). Cancel confirms first: it + fails the parked tool call. +- The composer's "Insert from MCP" (mecatui's f8 prompts and ctrl+r + resources pickers: `mcp-prompt-picker.tsx`, `mcp-resource-picker.tsx`, + owned by `ChatInput` through `useMcpComposerInsert`) is gated on + `serverCapabilities.mcp` — a daemon granting only `mcp_connector_status` + never receives a prompt or resource RPC — and the picked text is APPENDED + after the draft as paragraphs (`appendComposerText`), reviewed, never sent. + Prompts insert the TUI's role-prefixed join (`renderPromptForComposer`); + a resource's binary chunks are named, never inserted; every insertion is + clamped to 64 KiB with a note. The Tools shortcuts — `mcp.inventory` ⌘., + `mcp.resources` ⌘;, `mcp.prompts` ⌘' — are registered only while the + capability is on (the chords keep their native meaning otherwise); they + ride punctuation because every mnemonic letter chord is bound, browser- + reserved or a Firefox DevTools key (the registry's Tools note has the + audit). `requestOpenMcpPicker(kind)` is the outside opener for a slash + built-in; the composer that last held focus answers. (`harness/mcp.test.ts`, + `mcp-prompt-picker.test.tsx`, `mcp-resource-picker.test.tsx`, + `chat-input-mcp-insert.test.tsx`, `registry.test.ts`, the Playwright + MCP prompt / resource tests.) +- The per-chat / per-schedule TOOL PROFILE (the daemon's + `CreateSessionRequest.profile`, `""` | `"no-fs"`, ADR 0291 — the web + analogue of a shell-less run) is picked from the composer's own Tools + pill (`ToolProfileSelector` in `tool-profile-picker.tsx`, next to Mode; + the mobile mode sheet keeps the rows) on a DRAFT only and rides the mint (`useSessionMode().profileRef` → + `useAgentChat({ createProfile })` → `createHarnessSession({ profile })`, + `""` omits the key so the ordinary create body is unchanged). The daemon + fixes it at create and reports it on NO snapshot or row, so a live chat + shows only what Studio remembered choosing + (`src/lib/session-profile-memory.ts`, browser-local, bounded; a chat + minted elsewhere is UNKNOWN and shows no line — never a guessed default). + Schedules own the same field in the form ("Tool profile" select, + `schedule-form.tsx`) and the detail page's Tools fact + `no filesystem` + badge. `serverCapabilities.bash === false` (the operator's `--no-shell`) + adds a muted "Shell is disabled on this daemon" note; there is no + capability flag for profiles, so a pre-0291 daemon's 400 is shown + verbatim on the draft. (`tool-profile.test.ts`, + `session-profile-memory.test.ts`, `create-body.test.ts`, + `use-session-mode.test.ts`, `use-agent-chat.profile.test.ts`, + `tool-profile-picker.test.tsx`, `schedule-form.test.tsx`, + `use-agent-cron.test.ts`.) +- The composer has NO memory pill: memory is a daemon setting + (`--memory-dir`, `--no-user-model`) with no per-session API, so the only + place it is shown or changed is Settings → Memory + (`memory-stores-card.tsx` reads the daemon's `serverCapabilities.memory` + / `user_model` through `readMemoryStores`). Do not add a per-chat toggle: + it could never reach the daemon. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/studio/Taskfile.yml b/studio/Taskfile.yml new file mode 100644 index 0000000000..f52e97ec8d --- /dev/null +++ b/studio/Taskfile.yml @@ -0,0 +1,106 @@ +version: '3' + +# Mecatl Studio — the local web client for the harness. +# Run from the repo root via `task studio:*` (the root Taskfile includes this +# file under the `studio` namespace with dir: studio). +# +# Studio drives the mecated in THIS checkout: `task studio:dev` starts a +# supervisor that spawns ../bin/mecated against the repo root as its workspace, +# so `task build` must have run first. +# +# Studio consumes the daemon through the TypeScript SDK (sdk/typescript), which +# package.json pins as a `file:../sdk/typescript` dependency. npm links the +# SDK's checkout in place — it does NOT build it — so `install` builds the SDK +# first (`task sdk:build`, which runs the frozen pnpm install itself). + +vars: + PORT: 3000 + LOG: dev.log + +tasks: + install: + desc: Install npm dependencies (skipped when package files are unchanged) + sources: + - package.json + - package-lock.json + - ../sdk/typescript/package.json + - ../sdk/typescript/src/**/*.ts + generates: + - node_modules/.package-lock.json + - ../sdk/typescript/dist/index.js + cmds: + # The SDK is a file: dependency — build it before npm links it in. + - task --taskfile ../Taskfile.yml sdk:build + # Pinned via npx: a system npm 11 rewrites the lockfile in a shape + # npm 10 (what CI's Node 22 ships) rejects, breaking npm ci. + - npx -y npm@10.9.4 install + + dev: + desc: Start Studio + its mecated supervisor in the background (runs install if needed) + deps: [install] + cmds: + - | + if [ -z "${MECATL_BASE_URL:-}" ] && [ ! -x ../bin/mecated ]; then + echo "../bin/mecated not found — run 'task build' first" >&2 + exit 1 + fi + if lsof -t -i TCP:{{.PORT}} -s TCP:LISTEN > /dev/null 2>&1; then + echo "Studio already running — http://localhost:{{.PORT}}" + else + bash -c 'npm run dev >> {{.LOG}} 2>&1 & disown $!' + echo "Studio starting — http://localhost:{{.PORT}}" + echo "Logs: tail -f studio/{{.LOG}}" + fi + + stop: + desc: Stop Studio (web server + controller + the mecated it supervises) + cmds: + - | + # The controller (8788) owns the mecated child, so stopping it first + # lets the supervisor tear the daemon down rather than orphaning it. + for PORT in 8788 {{.PORT}}; do + PID=$(lsof -t -i TCP:$PORT -s TCP:LISTEN || true) + if [ -n "$PID" ]; then /bin/kill $PID && echo "Stopped listener on $PORT"; fi + done + + restart: + desc: Restart Studio + cmds: + - task: stop + - task: dev + + status: + desc: Show Studio dev server status + cmds: + - | + PID=$(lsof -t -i TCP:{{.PORT}} -s TCP:LISTEN || true) + if [ -n "$PID" ]; then + echo "Running (PID $PID) — http://localhost:{{.PORT}}" + else + echo "Not running" + fi + + build: + desc: Build Studio into studio/.next/ + deps: [install] + cmds: + - npm run build + + test: + desc: Run Studio's unit suite and the hermetic server-tier suite + deps: [install] + cmds: + - npx vitest run + - npm run test:server + + lint: + desc: Run Biome over studio/ + deps: [install] + cmds: + - npm run lint + + typecheck: + desc: Run TypeScript type-check over studio/ + deps: [install] + cmds: + - npm run typecheck diff --git a/studio/biome.json b/studio/biome.json new file mode 100644 index 0000000000..58eab024f1 --- /dev/null +++ b/studio/biome.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["**", "!node_modules", "!.next", "!dist", "!build"] + }, + "overrides": [ + { + "includes": ["src/components/ui/sidebar.tsx"], + "linter": { + "rules": { + "suspicious": { + "noDocumentCookie": "off" + } + } + } + } + ], + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noUnknownAtRules": "off" + } + }, + "domains": { + "next": "recommended", + "react": "recommended" + } + }, + "css": { + "parser": { + "cssModules": false, + "tailwindDirectives": true + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/studio/components.json b/studio/components.json new file mode 100644 index 0000000000..f4fb4bea2d --- /dev/null +++ b/studio/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/studio/knip.ts b/studio/knip.ts new file mode 100644 index 0000000000..9afab46dbf --- /dev/null +++ b/studio/knip.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { KnipConfig } from "knip"; + +const config: KnipConfig = { + entry: [ + "src/app/**/{page,layout,loading,error,not-found,global-error,route}.{ts,tsx}", + "src/**/*.test.{ts,tsx}", + ], + project: ["src/**/*.{ts,tsx}"], + paths: { + "@/*": ["src/*"], + }, + ignore: [ + // shadcn/ui components export all variants by convention — only a subset + // is used at any given time + "src/components/ui/**", + // Feature modules: internal exports consumed within the feature + "src/features/**", + ], + ignoreBinaries: [ + // The controller's daemon lifetime pipe and the ToolHive CLI it probes + // (scripts/local-controller.mjs). + "mkfifo", + "thv", + // The hermetic suite mints a throwaway TLS certificate for its fake + // upstream (tests/rendered-html.test.mjs) and skips those assertions + // when the binary is missing. + "openssl", + ], + ignoreDependencies: [ + // Tailwind v4 is imported via CSS (@import "tailwindcss"), not JS + "tailwindcss", + // Used by shadcn/ui Form and Label components (in src/components/ui/ which knip ignores) + "react-hook-form", + // Used by shadcn/ui components (in src/components/ui/ which knip ignores) + "@radix-ui/react-avatar", + "@radix-ui/react-popover", + "@radix-ui/react-progress", + "@radix-ui/react-separator", + "@radix-ui/react-toggle", + "@radix-ui/react-toggle-group", + ], +}; + +export default config; diff --git a/studio/next.config.ts b/studio/next.config.ts new file mode 100644 index 0000000000..9a8d28081f --- /dev/null +++ b/studio/next.config.ts @@ -0,0 +1,151 @@ +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { NextConfig } from "next"; + +const isDev = process.env.NODE_ENV !== "production"; + +/** + * Studio's client build stamp — the web analogue of mecatui's linker-stamped + * `buildinfo.BuildID`, shown on Settings → Provider → About and printed on + * the `/diagnostics` report's `client build:` line (src/lib/studio-build.ts). + * + * `MECATL_STUDIO_BUILD` wins when the build environment sets it (a CI image + * naming its release); otherwise `+`, + * or `+dev` when the build tree has no `.git` (a source tarball). + * It is inlined at `next build` via the `env` block below, so `next start` + * reports the stamp of the build it serves — a bug report wants the build, + * not the checkout that happens to be running it. + */ +function studioBuildId(): string { + const explicit = (process.env.MECATL_STUDIO_BUILD ?? "").trim(); + if (explicit) return explicit; + let version = "0.0.0"; + try { + const pkg = JSON.parse( + readFileSync(resolve(import.meta.dirname, "package.json"), "utf8"), + ) as { version?: string }; + if (pkg.version) version = pkg.version; + } catch { + // keep the placeholder version + } + let sha = "dev"; + try { + sha = execSync("git rev-parse --short HEAD", { + cwd: import.meta.dirname, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + } catch { + // no .git in the build tree + } + return `${version}+${sha || "dev"}`; +} + +/** + * The first readable `version` among candidate package.json paths, or "". + * Settings → Help & about shows Studio's own version and the TypeScript + * SDK's (`src/lib/studio-version.ts`); the SDK is read from the INSTALLED + * package first (the `file:` link in the monorepo, a real copy elsewhere) + * and from the monorepo source as the fallback, so a build tree without + * `../sdk` still names the SDK it compiled against. + */ +function packageVersion(...candidates: string[]): string { + for (const candidate of candidates) { + try { + const pkg = JSON.parse(readFileSync(candidate, "utf8")) as { + version?: string; + }; + if (pkg.version) return pkg.version; + } catch { + // try the next candidate + } + } + return ""; +} + +/** + * Content Security Policy header. + * Every daemon/controller call happens server-side through the /api/mecatl* + * proxy routes, so the browser CSP only needs 'self'. + */ +const cspHeader = ` + default-src 'self'; + script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}; + style-src 'self' 'unsafe-inline'; + img-src 'self' blob: data:; + font-src 'self'; + connect-src 'self'; + form-action 'self'; + frame-ancestors 'none'; + base-uri 'self'; + object-src 'none'; + ${process.env.NODE_ENV === "production" ? "upgrade-insecure-requests;" : ""} +` + .replace(/\s{2,}/g, " ") + .trim(); + +/** + * Hostnames allowed to request dev-server assets, derived from the same + * MECATL_STUDIO_PUBLIC_ORIGIN allowlist the API proxy trusts — one knob for + * LAN access. Without this, Next's dev cross-origin protection 403s the + * /_next/* chunks for a non-localhost Host, so the page renders but never + * hydrates. Dev-only: `next start` has no such gate. + */ +const allowedDevOrigins = (process.env.MECATL_STUDIO_PUBLIC_ORIGIN || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean) + .map((origin) => { + try { + return new URL(origin).hostname; + } catch { + return origin; + } + }); + +const nextConfig: NextConfig = { + reactCompiler: true, + poweredByHeader: false, + // Inlined into both bundles at build time; read via `studioBuild()`, + // `studioVersion()` and `sdkVersion()` (Settings → Help & about). + env: { + NEXT_PUBLIC_STUDIO_BUILD: studioBuildId(), + NEXT_PUBLIC_STUDIO_VERSION: packageVersion( + resolve(import.meta.dirname, "package.json"), + ), + NEXT_PUBLIC_SDK_VERSION: packageVersion( + resolve( + import.meta.dirname, + "node_modules/@stacklok-oss/mecatl-sdk/package.json", + ), + resolve(import.meta.dirname, "../sdk/typescript/package.json"), + ), + }, + // The TypeScript SDK is a `file:../sdk/typescript` dependency — npm links it + // as a symlink pointing OUTSIDE studio/. Turbopack resolves modules only + // under its root, so the root is the monorepo checkout, not studio/. + turbopack: { root: resolve(import.meta.dirname, "..") }, + ...(allowedDevOrigins.length > 0 ? { allowedDevOrigins } : {}), + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: cspHeader }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/studio/package-lock.json b/studio/package-lock.json new file mode 100644 index 0000000000..5e4a146a04 --- /dev/null +++ b/studio/package-lock.json @@ -0,0 +1,8956 @@ +{ + "name": "mecatl-studio", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mecatl-studio", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@stacklok-oss/mecatl-sdk": "file:../sdk/typescript", + "@tiptap/extension-mention": "3.31.3", + "@tiptap/extension-placeholder": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", + "@tiptap/starter-kit": "3.31.3", + "@tiptap/suggestion": "3.31.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "^1.1.1", + "fflate": "^0.8.3", + "lucide-react": "^0.577.0", + "next": "16.3.5", + "next-themes": "^0.4.6", + "nuqs": "^2.8.1", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-hook-form": "^7.72.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", + "shiki": "^4.4.3", + "sonner": "^2.0.7", + "tailwind-merge": "3.5.0", + "tiptap-markdown": "^0.9.0", + "undici": ">=7.29.0 <8" + }, + "devDependencies": { + "@biomejs/biome": "2.4.10", + "@playwright/test": "^1.56.1", + "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.13.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "^4.0.8", + "babel-plugin-react-compiler": "1.0.0", + "jsdom": "^29.0.0", + "knip": "^6.1.1", + "postcss": "^8.5.18", + "tailwindcss": "^4", + "typescript": "^6.0.2", + "vite": "^8.0.16", + "vite-tsconfig-paths": "^6.0.0", + "vitest": "^4.0.8", + "vitest-fail-on-console": "^0.10.1" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "../sdk/typescript": { + "name": "@stacklok-oss/mecatl-sdk", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.14.1", + "@connectrpc/connect": "2.2.0", + "@connectrpc/connect-node": "2.2.0", + "@types/node": "24.13.3", + "ajv": "8.20.0" + }, + "devDependencies": { + "@biomejs/biome": "2.5.12", + "@microsoft/api-extractor": "7.59.0", + "@playwright/test": "1.63.0", + "typescript": "6.0.3", + "typescript-5.7": "npm:typescript@5.7.3", + "vitest": "4.1.11" + }, + "engines": { + "deno": ">=2.9.3 <3", + "node": ">=22" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.10.tgz", + "integrity": "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.10", + "@biomejs/cli-darwin-x64": "2.4.10", + "@biomejs/cli-linux-arm64": "2.4.10", + "@biomejs/cli-linux-arm64-musl": "2.4.10", + "@biomejs/cli-linux-x64": "2.4.10", + "@biomejs/cli-linux-x64-musl": "2.4.10", + "@biomejs/cli-win32-arm64": "2.4.10", + "@biomejs/cli-win32-x64": "2.4.10" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz", + "integrity": "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.10.tgz", + "integrity": "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.10.tgz", + "integrity": "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.10.tgz", + "integrity": "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.10.tgz", + "integrity": "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.10.tgz", + "integrity": "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.10.tgz", + "integrity": "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.10.tgz", + "integrity": "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "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/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "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.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "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.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "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.1.1", + "@csstools/css-calc": "^3.3.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.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "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/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "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.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@next/env": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", + "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", + "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.143.0.tgz", + "integrity": "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.143.0.tgz", + "integrity": "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.143.0.tgz", + "integrity": "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.143.0.tgz", + "integrity": "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.143.0.tgz", + "integrity": "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.143.0.tgz", + "integrity": "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.143.0.tgz", + "integrity": "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.143.0.tgz", + "integrity": "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.143.0.tgz", + "integrity": "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.143.0.tgz", + "integrity": "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.143.0.tgz", + "integrity": "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.143.0.tgz", + "integrity": "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.143.0.tgz", + "integrity": "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.143.0.tgz", + "integrity": "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.143.0.tgz", + "integrity": "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.143.0.tgz", + "integrity": "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.143.0.tgz", + "integrity": "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.143.0.tgz", + "integrity": "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.143.0.tgz", + "integrity": "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" + }, + "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": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.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-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.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-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.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-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "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.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "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": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "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.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "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": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "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-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "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.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.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-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.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-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.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-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.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-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.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": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" + }, + "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": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.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-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.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-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "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.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.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-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "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": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "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.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "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": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.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-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "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.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "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-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "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.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "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.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "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.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "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.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "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/@stacklok-oss/mecatl-sdk": { + "resolved": "../sdk/typescript", + "link": true + }, + "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/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "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", + "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/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "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/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tiptap/core": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-mention": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.31.3.tgz", + "integrity": "sha512-ygOPc3nQijSMUTbEdMeng12P1szk3znubA9xNi84f31VnR18VjmU0we8SIOSSVwDfm1GppSjRER2qHmX/wGIIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/suggestion": "3.31.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz", + "integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.31.3.tgz", + "integrity": "sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "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" + }, + "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/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/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "13.0.9", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz", + "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^3", + "@types/mdurl": "^1" + } + }, + "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/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", + "license": "MIT" + }, + "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": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "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/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "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/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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": "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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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==", + "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", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.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/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "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/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "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": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/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/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "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/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/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/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/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "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/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/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/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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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/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" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "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/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "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/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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.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/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.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/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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-tsconfig": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "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/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-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/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-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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-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-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-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-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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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/knip": { + "version": "6.32.2", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.32.2.tgz", + "integrity": "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.1", + "jiti": "^2.7.0", + "oxc-parser": "^0.143.0", + "oxc-resolver": "11.24.2", + "picomatch": "^4.0.5", + "smol-toml": "^1.7.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.17", + "unbash": "^4.0.9", + "yaml": "^2.9.0", + "zod": "^4.4.3" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "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/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-task-lists": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz", + "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", + "license": "ISC" + }, + "node_modules/markdown-it/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/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/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-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-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/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "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-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/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/next": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.5", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/nuqs": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.9.6.tgz", + "integrity": "sha512-JexjSkA6mPV5HjobxyYMMIoItbPLLz5KcVKkMJLLJJDRfd07nFZ1gr40du30l8KnsOtZ+yubhYkqRyRCQiqVDg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/franky47" + }, + "peerDependencies": { + "@remix-run/react": ">=2", + "@tanstack/react-router": "^1", + "next": ">=14.2.0", + "react": ">=18.2.0 || ^19.0.0-0", + "react-router": "^5 || ^6 || ^7 || ^8", + "react-router-dom": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/oxc-parser": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.143.0.tgz", + "integrity": "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.143.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.143.0", + "@oxc-parser/binding-android-arm64": "0.143.0", + "@oxc-parser/binding-darwin-arm64": "0.143.0", + "@oxc-parser/binding-darwin-x64": "0.143.0", + "@oxc-parser/binding-freebsd-x64": "0.143.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.143.0", + "@oxc-parser/binding-linux-arm64-musl": "0.143.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.143.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.143.0", + "@oxc-parser/binding-linux-x64-gnu": "0.143.0", + "@oxc-parser/binding-linux-x64-musl": "0.143.0", + "@oxc-parser/binding-openharmony-arm64": "0.143.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.143.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.143.0", + "@oxc-parser/binding-win32-x64-msvc": "0.143.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, + "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/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/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/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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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", + "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/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.6.tgz", + "integrity": "sha512-dY6g2BXRjkHW2ldNRDKfTF0x0R4ifk5rgPaABd/UhvrymtsGVxTbHn01goEsHAvO9nN0O34cttlW1qg/XUcaIg==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-markdown/node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/prosemirror-markdown/node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/prosemirror-markdown/node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "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/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-hook-form": { + "version": "7.85.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.85.0.tgz", + "integrity": "sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "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" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "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-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/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/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-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/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/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.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.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "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/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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/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/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/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "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-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "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/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "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/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.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tiptap-markdown": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", + "integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "dependencies": { + "@types/markdown-it": "^13.0.7", + "markdown-it": "^14.1.0", + "markdown-it-task-lists": "^2.1.1", + "prosemirror-markdown": "^1.11.1" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.1" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "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/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/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/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/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/unbash": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.10.tgz", + "integrity": "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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-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-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/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-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/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-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.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "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.4.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 + } + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", + "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + } + }, + "node_modules/vite-tsconfig-paths/node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "deprecated": "unmaintained", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths/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", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "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.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "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/vitest-fail-on-console": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/vitest-fail-on-console/-/vitest-fail-on-console-0.10.1.tgz", + "integrity": "sha512-Xjy2SpgND547qSy0s0zYVnh1G/WyGtdjAbi4PFV8mkYRmTq+6NzRUJYdc08BHrw7HJLpO2kMxHFB8PWn7FOVsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1" + }, + "peerDependencies": { + "@vitest/utils": ">=0.26.2", + "vite": ">=4.5.2", + "vitest": ">=0.26.2" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "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/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "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/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/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/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/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "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/studio/package.json b/studio/package.json new file mode 100644 index 0000000000..1ea6bb9326 --- /dev/null +++ b/studio/package.json @@ -0,0 +1,110 @@ +{ + "name": "mecatl-studio", + "version": "0.1.0", + "private": true, + "engines": { + "node": ">=22.13.0" + }, + "scripts": { + "dev": "node scripts/dev-local.mjs", + "dev:web": "next dev", + "mecatl": "node scripts/local-controller.mjs", + "build": "next build", + "start": "node scripts/dev-local.mjs --production", + "lint": "biome check", + "format": "biome format --write", + "test": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "npm run build && playwright test", + "typecheck": "tsc --noEmit", + "knip": "knip", + "test:server": "npm run build && node --test tests/rendered-html.test.mjs" + }, + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@stacklok-oss/mecatl-sdk": "file:../sdk/typescript", + "@tiptap/extension-mention": "3.31.3", + "@tiptap/extension-placeholder": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", + "@tiptap/starter-kit": "3.31.3", + "@tiptap/suggestion": "3.31.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "^1.1.1", + "fflate": "^0.8.3", + "lucide-react": "^0.577.0", + "next": "16.3.5", + "next-themes": "^0.4.6", + "nuqs": "^2.8.1", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-hook-form": "^7.72.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", + "shiki": "^4.4.3", + "sonner": "^2.0.7", + "tailwind-merge": "3.5.0", + "tiptap-markdown": "^0.9.0", + "undici": ">=7.29.0 <8" + }, + "devDependencies": { + "@biomejs/biome": "2.4.10", + "@playwright/test": "^1.56.1", + "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.13.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "^4.0.8", + "babel-plugin-react-compiler": "1.0.0", + "jsdom": "^29.0.0", + "knip": "^6.1.1", + "postcss": "^8.5.18", + "tailwindcss": "^4", + "typescript": "^6.0.2", + "vite": "^8.0.16", + "vite-tsconfig-paths": "^6.0.0", + "vitest": "^4.0.8", + "vitest-fail-on-console": "^0.10.1" + }, + "overrides": { + "undici": ">=7.29.0 <8", + "picomatch": ">=4.0.4", + "linkify-it": ">=5.0.2 <6", + "sharp": ">=0.35.4", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/extension-bubble-menu": "3.31.3", + "@tiptap/extension-floating-menu": "3.31.3" + }, + "packageManager": "npm@10.9.4", + "knip": { + "ignoreBinaries": [ + "mkfifo", + "thv" + ] + } +} diff --git a/studio/playwright.config.mts b/studio/playwright.config.mts new file mode 100644 index 0000000000..3d57d7875b --- /dev/null +++ b/studio/playwright.config.mts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from "@playwright/test"; + +const BASE_URL = process.env.BASE_URL || "http://127.0.0.1:3300"; +const FIXTURE_PORT = 8099; + +/** + * E2E runs the production build in EXTERNAL mode against the fixture daemon + * (tests/e2e/fixture-daemon.mjs): browser → real proxy tier → fixture wire. + * `npm run test:e2e` builds first. + */ +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + timeout: 30_000, + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: [ + { + command: `node tests/e2e/fixture-daemon.mjs`, + url: `http://127.0.0.1:${FIXTURE_PORT}/v1/models`, + timeout: 15_000, + env: { FIXTURE_DAEMON_PORT: String(FIXTURE_PORT) }, + }, + { + command: `npx next start -p ${new URL(BASE_URL).port}`, + url: BASE_URL, + timeout: 120_000, + env: { + MECATL_BASE_URL: `http://127.0.0.1:${FIXTURE_PORT}`, + MECATL_WORKSPACE: "/workspace/fixture", + MECATL_STUDIO_PUBLIC_ORIGIN: BASE_URL, + }, + }, + ], +}); diff --git a/studio/postcss.config.mjs b/studio/postcss.config.mjs new file mode 100644 index 0000000000..61e36849cf --- /dev/null +++ b/studio/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/studio/public/.gitkeep b/studio/public/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/public/apple-touch-icon.png b/studio/public/apple-touch-icon.png new file mode 100644 index 0000000000..9de0f1484f Binary files /dev/null and b/studio/public/apple-touch-icon.png differ diff --git a/studio/public/bg-pattern.png b/studio/public/bg-pattern.png new file mode 100644 index 0000000000..c364ae35b7 Binary files /dev/null and b/studio/public/bg-pattern.png differ diff --git a/studio/public/icon-192.png b/studio/public/icon-192.png new file mode 100644 index 0000000000..06a25afd71 Binary files /dev/null and b/studio/public/icon-192.png differ diff --git a/studio/public/icon-512.png b/studio/public/icon-512.png new file mode 100644 index 0000000000..5e2218850b Binary files /dev/null and b/studio/public/icon-512.png differ diff --git a/studio/public/icon-maskable-512.png b/studio/public/icon-maskable-512.png new file mode 100644 index 0000000000..a0832e38a3 Binary files /dev/null and b/studio/public/icon-maskable-512.png differ diff --git a/studio/public/signin-pattern.svg b/studio/public/signin-pattern.svg new file mode 100644 index 0000000000..e2bc6a600e --- /dev/null +++ b/studio/public/signin-pattern.svg @@ -0,0 +1,466 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/studio/public/stacklok-favicon.png b/studio/public/stacklok-favicon.png new file mode 100644 index 0000000000..d20ed901a2 Binary files /dev/null and b/studio/public/stacklok-favicon.png differ diff --git a/studio/public/stacklok-logo-mark.svg b/studio/public/stacklok-logo-mark.svg new file mode 100644 index 0000000000..0ab1ef082f --- /dev/null +++ b/studio/public/stacklok-logo-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio/public/stacklok-logo.svg b/studio/public/stacklok-logo.svg new file mode 100644 index 0000000000..aac75c4535 --- /dev/null +++ b/studio/public/stacklok-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/studio/scripts/dev-local.mjs b/studio/scripts/dev-local.mjs new file mode 100644 index 0000000000..b4946bc62d --- /dev/null +++ b/studio/scripts/dev-local.mjs @@ -0,0 +1,82 @@ +import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const controllerScript = resolve(root, "scripts/local-controller.mjs"); +const next = resolve(root, "node_modules/.bin/next"); +const production = process.argv.includes("--production"); +const externalMode = Boolean(process.env.MECATL_BASE_URL?.trim()); + +let stopping = false; +let controller = null; +let web = null; +let controllerRestart = null; +let webRestart = null; + +const delayRestart = (callback) => setTimeout(callback, 750); + +async function controllerIsHealthy() { + try { + const response = await fetch("http://127.0.0.1:8788/status", { + signal: AbortSignal.timeout(1_500), + }); + return response.ok; + } catch { + return false; + } +} + +async function ensureController() { + if (externalMode || stopping || controller || (await controllerIsHealthy())) + return; + controller = spawn(process.execPath, [controllerScript], { + cwd: root, + env: process.env, + stdio: "inherit", + }); + controller.once("exit", () => { + controller = null; + if (!stopping) + controllerRestart = delayRestart(() => { + controllerRestart = null; + void ensureController(); + }); + }); +} + +function startWeb() { + if (stopping || web) return; + web = spawn(next, production ? ["start"] : ["dev"], { + cwd: root, + env: process.env, + stdio: "inherit", + }); + web.once("exit", () => { + web = null; + if (!stopping) + webRestart = delayRestart(() => { + webRestart = null; + startWeb(); + }); + }); +} + +async function shutdown() { + if (stopping) return; + stopping = true; + if (controllerRestart) clearTimeout(controllerRestart); + if (webRestart) clearTimeout(webRestart); + clearInterval(healthCheck); + controller?.kill("SIGTERM"); + web?.kill("SIGTERM"); + setTimeout(() => process.exit(0), 2_000).unref(); +} + +for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, shutdown); + +await ensureController(); +startWeb(); +const healthCheck = setInterval(() => { + void ensureController(); +}, 3_000); diff --git a/studio/scripts/local-controller.mjs b/studio/scripts/local-controller.mjs new file mode 100644 index 0000000000..5ea8be7778 --- /dev/null +++ b/studio/scripts/local-controller.mjs @@ -0,0 +1,4420 @@ +import { execFile, spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { + appendFile, + lstat, + mkdir, + open, + readdir, + readFile, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import http from "node:http"; +import { homedir } from "node:os"; +import { basename, dirname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + diagnosticsOptionArgs, + diagnosticsRestartRequired, + mergeDiagnosticsOptions, + normalizeDiagnosticsOptions, + productMetricsEnvOptOut, +} from "../src/lib/controller-diagnostics-options.mjs"; +import { + createLogRing, + LOG_FILE_NAME, + MAX_LOG_FILE_BYTES, + parseTailLines, + ROTATED_LOG_FILE_NAME, + shouldRotate, + tailIsTruncated, +} from "../src/lib/controller-log.mjs"; +import { + freeLoopbackPort, + isAddressInUse, + PERF_PROXY_TIMEOUT_MS, + perfProxyRoute, + perfStatus, +} from "../src/lib/controller-perf.mjs"; +import { + normalizePermissions, + permissionArgs, +} from "../src/lib/controller-permissions.mjs"; +import { + requestIsAllowed, + validateGatewayURL, + validSkillName, +} from "../src/lib/controller-security.mjs"; +import { + hasProjectAuthority, + trustAnchor, + trustDecision, +} from "../src/lib/controller-trust.mjs"; +import { + DAEMON_DEFAULTS_EMPTY, + daemonDefaultArgs, + normalizeDaemonDefaults, +} from "../src/lib/daemon-defaults.mjs"; +import { + DEFAULT_COMMAND_DIRS, + DEFAULT_DAEMON_OPTIONS, + daemonOptionArgs, + daemonOptionDirFields, + effectiveDaemonDirs, + normalizeDaemonOptions, + optionDirScope, + optionDirWithinRoots, + resolveOptionDir, +} from "../src/lib/daemon-options.mjs"; +import { + CUSTOM_PROVIDER_API_FLAVORS, + customProviderProbeURL, + describeProviderRow, + describeToolhiveRow, + KNOWN_AUTH_PROVIDERS, + listAuthFileProviders, + listSettingsProviders, + PROVIDER_ENV_KEYS, + removeAuthFileProvider, + removeAuthFileProviderKey, + removeSettingsProvider, + upsertSettingsProvider, + validCustomProviderBaseURL, + validCustomProviderId, + validProviderName, +} from "../src/lib/provider-auth.mjs"; +import { + normalizeRetentionSettings, + retentionArgs, +} from "../src/lib/retention-settings.mjs"; +import { + DEFAULT_RUNTIME_SETTINGS, + effectiveRuntimeSettings, + NO_INHERITED_SETTINGS, + normalizeRuntimeSettings, + readLearningBlock, + readSteerScalar, + renderRuntimeSettingsYAML, + runtimeSettingsArgs, + runtimeSettingsHasYAML, + SOUL_MAX_BYTES, + soulFileWithinRoots, +} from "../src/lib/runtime-settings.mjs"; +import { + normalizeStorageSettings, + storageDefaultsFromEnv, + storageStatus, +} from "../src/lib/storage-settings.mjs"; +import { + memoryDirFor, + normalizeWorkspaceConfig, + skillsDirFor, + storageArgsFor, + storeDirFor, + validateWorkspacePath, + WORKSPACE_STATE_FILE, +} from "../src/lib/workspace-config.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +// Studio is a module INSIDE the mecatl monorepo, so the harness it drives is +// the repo root one directory up — the workspace it edits, the source of +// `bin/mecated` (built by `task build`), and the cwd every run inherits. +const mecatlDir = resolve(here, "../.."); +const binary = resolve(mecatlDir, "bin/mecated"); +// The DEFAULT workspace root. The LIVE root below is reassignable at runtime +// through POST /workspace (Settings → Workspace) — the web analogue of the +// TUI's `--workspace` deployment choice — and applyWorkspace re-derives +// every root-dependent directory, so the existing readers of these +// bindings need no per-site change. A saved root lives in +// studio-workspace.json; the default writes no file. +const defaultWorkspace = mecatlDir; +let workspace = defaultWorkspace; +const studioStateDir = resolve(here, "../.scratch"); +const workspaceStateFile = resolve(studioStateDir, WORKSPACE_STATE_FILE); +const operatorSettingsFile = resolve(studioStateDir, "operator-settings.yaml"); +const routerSettingsFile = resolve( + studioStateDir, + "model-router-settings.yaml", +); +const routerStateFile = resolve(studioStateDir, "model-router.json"); +// The Studio-owned RUNTIME SETTINGS (learning mode/sensitivity, the steer +// opt-out, the soul flags; src/lib/runtime-settings.mjs). Steer and soul are +// spawn flags; learning has no flag, so it is a second CLI-tier +// --permission-config file carrying the FULL merged `learning:` block +// (mecated captures that section whole-block, first file wins — a partial +// block would silently drop the operator's skills/automatic settings). The +// user-global settings.yaml is never edited. +const runtimeSettingsFile = resolve(studioStateDir, "runtime-settings.yaml"); +const runtimeStateFile = resolve(studioStateDir, "runtime-settings.json"); +// The Studio-owned permissions state (operator posture, project trust, +// shell-less mode). It becomes mecated CLI flags on every spawn — never a +// settings.yaml key — so an imported operator-settings.yaml is never +// rewritten by this controller. +const permissionsStateFile = resolve(studioStateDir, "permissions.json"); +// The Studio-owned session-store setting (durable dir or in-memory). Like +// permissions it becomes a spawn flag — `--store-dir ` or no flag at all +// — never a settings.yaml key; see src/lib/storage-settings.mjs. +const storageStateFile = resolve(studioStateDir, "storage-settings.json"); +// The Studio-owned retention document (per-family age/count limits, sweep +// cadence, main-deletion acknowledgement). Spawn flags that out-rank a +// settings file per field — never a settings.yaml `retention:` key; see +// src/lib/retention-settings.mjs. +const retentionStateFile = resolve(studioStateDir, "retention.json"); +// The Studio-owned DAEMON DEFAULTS (default/subagent model per provider, +// reasoning effort, context window, prompt caching, base-URL overrides, the +// ToolHive LLM gateway, aliases/slots, the credentials-file path) plus the +// durable active-provider choice. Every value becomes a mecated spawn flag +// (src/lib/daemon-defaults.mjs) — never a settings.yaml key — so the CLI +// tier wins even under an imported operator-settings.yaml. +const daemonDefaultsFile = resolve(studioStateDir, "daemon-defaults.json"); +// The Studio-owned DIAGNOSTICS OPTIONS (log level, the loopback +// admin/metrics listener + perf MCP + goroutine alarm, product-metrics +// opt-out) plus the controller-side `quiet` switch. Spawn flags again +// (src/lib/controller-diagnostics-options.mjs) — never a settings.yaml key. +// The operator POSTURE is NOT here: it is the permissions document's. +const diagnosticsOptionsFile = resolve( + studioStateDir, + "diagnostics-options.json", +); +// The Studio-owned DAEMON OPTIONS (src/lib/daemon-options.mjs): the +// per-project memory store on/off + location, the user model on/off + +// location + review interval, skill discovery on/off + location, slash +// commands on/off + location, and the four MCP discovery flags. Spawn flags +// again — never a settings.yaml key; the default document is the +// pre-feature command line byte for byte. Distinct from the diagnostics +// options above and the runtime settings (learning/steer/soul); `--no-shell` +// stays the permissions document's — one writer per flag. +const daemonOptionsFile = resolve(studioStateDir, "daemon-options.json"); +let daemonOptions = DEFAULT_DAEMON_OPTIONS; +// Skills are project-scoped by DEFAULT: a SKILL.md steers the model the same +// way AGENTS.md does, so discovery is pinned to /.mecatl/skills +// unless the daemon options relocate it (a trust decision the Tools page +// labels as such, confined to the workspace / the mecatl config dir), and we +// never pass --skills-conventional (which would also pull in ~/.claude/skills +// and the user-global mecatl dir — a much wider trust surface than this app +// should open). With `skills.enabled` off the directory is still owned and +// edited here; the spawn simply omits --skills-dir, which drops the Skill tool. +let skillsDir = skillsDirFor(workspace); +// Disabled skills are MOVED into a holding area inside the pinned skills dir, +// not deleted and not flagged: mecated's discovery walks only the direct +// children of --skills-dir looking for /SKILL.md, so a nested dir is +// invisible to it, and the skill-name grammar forbids a leading dot, so +// `.disabled` can never collide with a real skill. +let disabledSkillsDir = resolve(skillsDir, ".disabled"); +// Per-project memory (the Remember/Recall/SearchMemory tools) is OFF in mecated +// until --memory-dir is passed, unlike the user model which is on by default. The +// store is per-project by design, so it lives beside the session store rather than +// in a shared location. Consolidation stays off: it spends tokens in the background. +// A NON-default root keys its memory (and its session store) by a hash of +// its path UNDER THE DEFAULT ROOT's .scratch (src/lib/workspace-config.mjs): +// per-root, but never sprouting state inside a foreign repo. +let memoryDir = memoryDirFor(workspace, defaultWorkspace); + +/** + * Points the LIVE root at `root` and re-derives every root-dependent + * directory: project skills follow the root (the one directory the + * controller creates inside it), memory and the session store are keyed + * per root under the default root's .scratch. The trust registry and the + * spawn flags read `workspace` at spawn time, so the next startMecatl + * picks the new root up with no further plumbing. + */ +function applyWorkspace(root) { + workspace = root; + applyDaemonOptions(daemonOptions); +} + +/** The pinned/default locations the daemon options fall back to for the + * LIVE root — what `daemonOptionArgs` renders when no override is saved + * and what GET /daemon-options reports as the placeholders. */ +function daemonOptionContext() { + return { + workspace, + pinnedSkillsDir: skillsDirFor(workspace), + defaultMemoryDir: memoryDirFor(workspace, defaultWorkspace), + }; +} + +/** + * Adopts a daemon-options document and re-derives the two directories the + * controller itself reads (the skills CRUD routes, /status): the override + * when saved — a relative one resolves against the live root, so a root + * change (POST /workspace) moves it along — else the pinned/default + * location. Called by applyWorkspace too, so the two stay one derivation. + */ +function applyDaemonOptions(next) { + daemonOptions = next; + const dirs = effectiveDaemonDirs(daemonOptions, daemonOptionContext()); + skillsDir = dirs.skillsDir; + disabledSkillsDir = resolve(skillsDir, ".disabled"); + memoryDir = dirs.memoryDir; +} +// mecated's user-global config directory (XDG). The daemon defaults' +// `apiKeyFile` is confined to it: the controller reads AND rewrites the +// credentials file for the provider inventory/removal routes, so a +// browser-settable path must never reach outside this directory. +const mecatlConfigDir = process.env.XDG_CONFIG_HOME + ? resolve(process.env.XDG_CONFIG_HOME, "mecatl") + : resolve(homedir(), ".config/mecatl"); +const defaultAuthFile = resolve(mecatlConfigDir, "auth.yaml"); +// The EFFECTIVE credentials file: the default above, or the saved +// `--api-key-file` path (reassigned by applyDaemonDefaults) so the +// inventory, key-test and removal routes read the same file the daemon does. +let authFile = defaultAuthFile; +// The user-global operator settings file, same XDG convention as auth.yaml. +// mecated always reads it at the operator tier; it is where a hand-added +// custom `providers:` block (ADR 0238) lives unless an imported +// operator-settings.yaml (a CLI-tier file, which wins whole-block) carries +// its own. +const userSettingsFile = process.env.XDG_CONFIG_HOME + ? resolve(process.env.XDG_CONFIG_HOME, "mecatl/settings.yaml") + : resolve(homedir(), ".config/mecatl/settings.yaml"); +// The conventional soul file mecated loads when no --soul-file is passed +// (internal/adapter/soul/store.go soulSubpath) — the placeholder the +// Persona card shows, and the first of the two roots a browser-chosen +// --soul-file must sit under (the other is the workspace). A soul is +// injected into every turn's prompt and GET /v1/soul echoes its content, so +// an unconstrained path would turn the browser into a file-read oracle over +// anything the daemon's user can read (auth.yaml, SSH keys). +const userSoulFile = resolve(mecatlConfigDir, "soul.md"); +// A function, not a frozen array: the live root can change (POST /workspace). +const soulFileRoots = () => [mecatlConfigDir, workspace]; +// mecated's conventional user-model store when no --user-model-dir is passed +// (cmd/mecated flag help) — the Memory page's placeholder, display only. +const defaultUserModelDir = resolve(mecatlConfigDir, "usermodel"); +// Where a browser-chosen daemon-options directory (skills, project memory, +// user model, commands) may sit: the live root, the default root (a +// non-default root's memory store lives under its .scratch) or the mecatl +// config dir. A SKILL.md or a command template steers the model like +// AGENTS.md, and the controller mkdir's the skills/memory dirs before every +// spawn, so an unconstrained path would let the browser plant model-steering +// content — or create directories — anywhere the daemon's user can write. +const optionDirRoots = () => [workspace, defaultWorkspace, mecatlConfigDir]; +// mecated's --ready-file target: the atomically-published mecated-ready/1 +// document carrying the RESOLVED listener addresses (the daemon binds +// 127.0.0.1:0 and reports what the kernel picked), pid, api_major, features, +// and deployment. Written only after every listener is up, never removed by +// the daemon — the controller unlinks the stale one before each spawn. +const readyFilePath = resolve(studioStateDir, "mecated-ready.json"); +// The managed daemon's diagnostics log — Studio's analogue of mecatui's +// embedded-server log file ($XDG_STATE_HOME/mecatl/mecatui.log). mecated has +// no log-file flag (its diagnostics go to stderr), so the controller holding +// that stream appends it here: owner-only inside the 0700 state dir, ONE +// rotated generation at mecatui's 10 MiB bound, plus a bounded in-memory +// ring the Diagnostics page reads through GET /logs. Model-influenced +// content (prompt fragments, provider error bodies) — never rendered as +// markup, served only behind the studio header. +const mecatedLogFile = resolve(studioStateDir, LOG_FILE_NAME); +const rotatedLogFile = resolve(studioStateDir, ROTATED_LOG_FILE_NAME); +const logRing = createLogRing(); +// The named FIFO backing --lifetime-pipe-fd. mecated fstat's the descriptor +// and rejects anything that is not a real pipe (S_IFIFO) — and Node's stdio +// "pipe" entries are AF_UNIX socketpairs — so the pipe is made with +// mkfifo(1) and its NAME is unlinked as soon as both ends are open. +const lifetimeFifoPath = resolve(studioStateDir, "mecated-lifetime.fifo"); +// How long to wait for the ready file. Remote MCP gateways may cold-start and +// mecatl gives their initialize handshake up to 30 seconds; MCP construction +// happens during composition, which completes BEFORE the ready file is +// written, so the wait stays comfortably longer than that. +const readyWaitMs = 60_000; + +/** auth.yaml's text, or "" when it does not exist / cannot be read. */ +async function readAuthFileText() { + try { + return await readFile(authFile, "utf8"); + } catch { + return ""; + } +} + +/** + * Names only of the providers configured in auth.yaml — never their values. + * The line scan lives in src/lib/provider-auth.mjs (shared with its vitest + * suite): it deliberately cannot read a credential, only detect that a + * `providers:` block names a key at one level of indent. + */ +async function listConfiguredProviderNames() { + return listAuthFileProviders(await readAuthFileText()).map( + (provider) => provider.name, + ); +} + +/** + * The operator-defined custom providers (ADR 0238) mecated will actually + * see, mirroring its whole-block first-non-nil `providers:` capture across + * the operator tier: the imported operator-settings.yaml (the CLI-tier file + * this controller passes) is consulted first, then the user-global + * settings.yaml. The section is non-secret by design — ids, flavors, base + * URLs, auth methods; any key stays in auth.yaml. + */ +async function listCustomSettingsProviders() { + const sources = operatorSettingsActive + ? [operatorSettingsFile, userSettingsFile] + : [userSettingsFile]; + for (const file of sources) { + let text; + try { + text = await readFile(file, "utf8"); + } catch { + continue; + } + const providers = listSettingsProviders(text); + if (providers !== null) return providers; + } + return []; +} + +/** + * Every provider name the daemon can be started on: the auth.yaml blocks + * plus the settings-defined custom providers — a keyless + * (`auth.method: none`) custom provider never appears in auth.yaml, so the + * auth scan alone would refuse to select it. + */ +async function listSelectableProviderNames() { + const names = await listConfiguredProviderNames(); + for (const provider of await listCustomSettingsProviders()) { + if (!names.includes(provider.name)) names.push(provider.name); + } + // A built-in whose credential env var is set in this environment is + // registered by mecated from the variable alone (it inherits the env), so + // it is startable with no auth.yaml block at all. Presence only. + for (const kind of Object.keys(PROVIDER_ENV_KEYS)) { + if (providerEnvShadowed(kind) && !names.includes(kind)) names.push(kind); + } + return names; +} + +// ── Provider management ───────────────────────────────────────────────────── +// The provider inventory, guided add, key test, and removal are controller- +// owned for the same reason the skills routes are: mecated reads auth.yaml +// once at startup and has no HTTP write API for it. Every route keeps Studio +// rule 3 intact — a credential is read SERVER-SIDE here for exactly one +// outbound probe or removed from the file; no response body ever carries a +// key, not even a redacted preview, and there is no route that ACCEPTS one. + +/** + * One cheap authenticated read per testable provider, mirroring the base + * URLs the daemon itself defaults to (internal/cliconfig: the controller + * spawns mecated without --*-base-url overrides, so these defaults are what + * the key will actually be used against). OpenRouter's /models is public + * (the daemon's own lister is deliberately keyless), so its keyed metadata + * endpoint /key is the probe there. + */ +const providerKeyProbes = { + openrouter: (key) => ({ + url: "https://openrouter.ai/api/v1/key", + headers: { Authorization: `Bearer ${key}` }, + }), + openai: (key) => ({ + url: "https://api.openai.com/v1/models", + headers: { Authorization: `Bearer ${key}` }, + }), + anthropic: (key) => ({ + url: "https://api.anthropic.com/v1/models?limit=1", + headers: { "x-api-key": key, "anthropic-version": "2023-06-01" }, + }), + opencode: (key) => ({ + url: "https://opencode.ai/zen/go/v1/models", + headers: { Authorization: `Bearer ${key}` }, + }), +}; + +/** + * The named provider's api_key value, read server-side for the one outbound + * key probe. Deliberately controller-local (NOT in provider-auth.mjs, which + * the browser bundle imports) and deliberately api_key-only: openai-codex's + * oauth block is not key-testable. The value is never logged or echoed. + */ +function readProviderCredential(text, name) { + const lines = String(text ?? "").split("\n"); + const providersAt = lines.findIndex((line) => /^providers:\s*$/.test(line)); + if (providersAt === -1) return ""; + let inBlock = false; + for (const line of lines.slice(providersAt + 1)) { + if (/^\S/.test(line)) break; // dedented past the providers block + const key = line.match(/^ {2}([A-Za-z0-9_-]+):/); + if (key) { + inBlock = key[1] === name; + continue; + } + if (!inBlock) continue; + const credential = line.match(/^\s+api_key:\s*(.+)$/); + if (!credential) continue; + let value = credential[1].trim(); + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.endsWith(quote)) { + value = value.slice(1, -1); + } + return value.startsWith("#") ? "" : value; + } + return ""; +} + +/** Provider error text, bounded and de-control-charred before it reaches a + * response body (it is provider-authored, not ours). */ +function clampProviderError(text) { + return ( + String(text ?? "") + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 300) + ); +} + +/** + * The one keyed probe request for a CUSTOM (settings-defined, ADR 0238) + * provider: a models listing against ITS configured base URL, with the + * header shape its api_flavor dictates. Controller-local for the same reason + * readProviderCredential is — it carries the key. + */ +function customProviderKeyProbe(definition, key) { + const url = customProviderProbeURL(definition.apiFlavor, definition.baseURL); + if (definition.apiFlavor === "anthropic-messages") { + return { + url, + headers: { "x-api-key": key, "anthropic-version": "2023-06-01" }, + }; + } + return { url, headers: { Authorization: `Bearer ${key}` } }; +} + +/** + * Runs the one bounded probe ({url, headers}) for a provider's stored key. + * Returns the JSON the route answers with: + * {ok:true} | {ok:false, status, rejected?, error}. + * A 401/403 is the provider saying the KEY is bad; anything else (5xx, + * timeout, DNS) is an infrastructure answer, reported distinctly so a red + * "key rejected" dot is never shown for a provider outage. + */ +async function probeProviderKey(probe) { + let response; + try { + response = await fetch(probe.url, { + headers: { Accept: "application/json", ...probe.headers }, + redirect: "manual", // never replay the credential to a redirect target + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + return { + ok: false, + status: 0, + error: clampProviderError(error?.message || "provider unreachable"), + }; + } + const body = await response.text().catch(() => ""); + if (response.ok) return { ok: true }; + if (response.status === 401 || response.status === 403) { + return { + ok: false, + status: response.status, + rejected: true, + error: `key rejected (HTTP ${response.status})`, + }; + } + return { + ok: false, + status: response.status, + error: clampProviderError(body) || `HTTP ${response.status}`, + }; +} +// ── Workspace skills management ──────────────────────────────────────────── +// The daemon has no HTTP write API for skills (Studio shipped them read-only; +// authoring is the ADR-0233 backlog item), and it resolves the skills dir +// ONCE at startup: skillfs's FSSource is a construction-time snapshot +// ("bodies are retained; no re-read") and internal/app/build.go registers +// "the skills resolved once at build time". So skill CRUD lives here, on the +// controller that owns --skills-dir, and every mutation the daemon can see +// restarts mecated through the same queueRestart machinery as config writes — +// in-flight runs and session ids die with it, exactly like a gateway or +// model-router write. + +/** Max SKILL.md body accepted on the edit path. */ +const maxSkillBodyBytes = 262_144; + +// Multi-file create caps (a zip/folder upload): a skill is a small folder of +// instructions plus a few assets, never a repository. +const maxSkillUploadFiles = 200; +const maxSkillUploadFileBytes = 2 * 1024 * 1024; +const maxSkillUploadTotalBytes = 8 * 1024 * 1024; +// The create body cap: the total decoded cap, base64-inflated, plus headroom. +const maxSkillCreateBodyBytes = 12 * 1024 * 1024; + +/** The controller's own guard on an uploaded relative path — the browser + * plans uploads too, but the server side is the one that counts. */ +function validSkillUploadPath(path) { + if (typeof path !== "string" || path === "" || path.includes("\\")) + return false; + return path + .split("/") + .every( + (segment) => + segment !== "" && + segment !== "." && + segment !== ".." && + !segment.startsWith("."), + ); +} + +function skillClientError(message, statusCode = 400) { + return Object.assign(new Error(message), { statusCode }); +} + +/** + * The enabled/disabled directory pair for a validated skill name. The grammar + * already forbids separators, dots, and whitespace; the prefix check is + * defense-in-depth should the grammar ever loosen. + */ +function skillPaths(name) { + if (!validSkillName(name)) + throw skillClientError( + "Skill names use lowercase letters, digits, hyphens, and underscores (max 64 characters)", + ); + const enabled = resolve(skillsDir, name); + const disabled = resolve(disabledSkillsDir, name); + if ( + !enabled.startsWith(skillsDir + sep) || + !disabled.startsWith(disabledSkillsDir + sep) + ) + throw skillClientError("Skill name escapes the skills directory"); + return { enabled, disabled }; +} + +async function isDirectory(path) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +/** + * One-line `description:` scan of a SKILL.md frontmatter block — a line scan, + * never a YAML parse, mirroring listConfiguredProviderNames. Best-effort: a + * block-scalar or absent description simply lists as "". + */ +function skillDescription(markdown) { + const lines = markdown.split("\n"); + if (lines[0]?.trim() !== "---") return ""; + for (const line of lines.slice(1)) { + if (line.trim() === "---") break; + const match = line.match(/^description:\s*(.+)$/); + if (!match) continue; + const value = match[1].trim(); + if (/^[>|]/.test(value)) return ""; + return value.replace(/^["']|["']$/g, ""); + } + return ""; +} + +/** Cap on the bundled-file listing — a skill is a small folder, not a repo. */ +const maxSkillFiles = 500; + +/** + * Bounded recursive listing of one skill's folder: relative POSIX paths + + * sizes. Symlinks are never followed (a link could point outside the skills + * dir), dot-entries are skipped (.DS_Store noise), and the walk stops at + * maxSkillFiles entries / depth 8. + */ +async function listSkillFiles(root) { + const files = []; + async function walk(dir, prefix, depth) { + if (depth > 8 || files.length >= maxSkillFiles) return; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (files.length >= maxSkillFiles) return; + if (entry.name.startsWith(".") || entry.isSymbolicLink()) continue; + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(resolve(dir, entry.name), rel, depth + 1); + } else if (entry.isFile()) { + try { + files.push({ + path: rel, + size: (await stat(resolve(dir, entry.name))).size, + }); + } catch { + // Raced away between readdir and stat — skip it. + } + } + } + } + await walk(root, "", 0); + return files; +} + +/** Names (+ best-effort descriptions) in the `.disabled/` holding area. */ +async function listDisabledSkills() { + let entries; + try { + entries = await readdir(disabledSkillsDir, { withFileTypes: true }); + } catch { + return []; + } + const skills = []; + for (const entry of entries) { + if (!entry.isDirectory() || !validSkillName(entry.name)) continue; + let description = ""; + try { + description = skillDescription( + await readFile( + resolve(disabledSkillsDir, entry.name, "SKILL.md"), + "utf8", + ), + ); + } catch { + // A SKILL.md-less folder still lists — enabling it back is how the + // operator recovers it. + } + skills.push({ name: entry.name, description }); + } + return skills.sort((a, b) => a.name.localeCompare(b.name)); +} + +// The kinds startMecatl/preferredKind understand: the two synthetic ones plus +// every provider auth.yaml can name a block for (KNOWN_AUTH_PROVIDERS is the +// same registry the guided-add UI offers). +const KNOWN_PROVIDER_KINDS = new Set([ + "mock", + "toolhive", + ...KNOWN_AUTH_PROVIDERS.map((entry) => entry.name), +]); +const configuredProvider = + process.env.MECATL_STUDIO_PROVIDER?.trim().toLowerCase() || ""; +if (configuredProvider && !KNOWN_PROVIDER_KINDS.has(configuredProvider)) { + throw new Error( + `MECATL_STUDIO_PROVIDER must be one of: ${[...KNOWN_PROVIDER_KINDS].join(", ")}`, + ); +} +// The LIVE active-provider selection. Seeded from MECATL_STUDIO_PROVIDER at +// startup, but — unlike that env var, which is frozen for the process's +// lifetime — reassignable at runtime through POST /providers/active (the +// Studio settings UI's provider switch), so an operator can move between the +// offline mock and a real provider without restarting `npm run dev`. +let activeProviderOverride = configuredProvider || null; +// Session-store defaults: MECATL_STUDIO_STORE_DIR (absolute or +// workspace-relative; default .scratch/studio-sessions under the workspace) +// and MECATL_STUDIO_NO_STORE=1 (in-memory: no --store-dir at all). A saved +// storage-settings.json (POST /storage) overrides both; a bad value throws +// here like a bad MECATL_STUDIO_PROVIDER does. +const storageDefaults = storageDefaultsFromEnv(process.env); +const managedAuthToken = ( + process.env.MECATL_AUTH_TOKEN || randomBytes(32).toString("base64url") +).replace(/^Bearer\s+/i, ""); +const mcpProxySecret = randomBytes(24).toString("base64url"); +const studioPublicOrigin = + process.env.MECATL_STUDIO_PUBLIC_ORIGIN?.trim() || "http://localhost:3000"; +const allowedOrigins = new Set( + ( + process.env.MECATL_STUDIO_ORIGINS || + "http://localhost:3000,http://127.0.0.1:3000" + ) + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean), +); +allowedOrigins.add(studioPublicOrigin); +let child = null; +let provider = "offline mock"; +let mecatlBaseURL = ""; +// What the child's ready file reported at the last successful start: the +// non-secret compatibility descriptor a parent may surface (H1.3). Null +// until a child has published one. +let readyInfo = null; +let gateway = null; +let modelRouterConfig = null; +// The saved runtime-settings document (PUT /runtime-settings), loaded from +// runtimeStateFile before the first spawn; the default document adds no +// flag and no file, so the command line is byte-identical to before. +let runtimeSettings = DEFAULT_RUNTIME_SETTINGS; +// `--approve-soul` rides EXACTLY ONE spawn (POST /soul/approve): it rewrites +// the drift baseline to the current soul, so persisting it would silently +// re-accept every later edit. Never written to disk. +let approveSoulPending = false; +let operatorSettingsActive = false; +// The saved permissions document (posture / trustProject / noShell), loaded +// from permissionsStateFile before the first spawn; defaults are mecated's +// own (strict, untrusted, shell on). +let permissionsConfig = normalizePermissions({}); +// The saved diagnostics-options document (POST /diagnostics-options), loaded +// from diagnosticsOptionsFile before the first spawn; defaults are mecated's +// own (info logging, product metrics on) with the admin listener explicitly +// OFF and the controller mirroring mecated's stderr. +let diagnosticsOptions = normalizeDiagnosticsOptions({}); +// The loopback admin/metrics address (`host:port`) the CURRENT child was +// spawned with as `--metrics-addr`, or "" while the surface is off. The +// controller CHOOSES it per spawn (mecated's ready file names only +// http_address), so a restart may move it; GET /perf reports the live one. +let adminAddr = ""; +// The SAVED storage document (POST /storage), or null while the env/built-in +// defaults still apply — the same null-means-unsaved shape as +// modelRouterConfig, so a failed restart can roll back to "no file". +let storageSettings = null; +const effectiveStorage = () => storageSettings ?? storageDefaults; +// The `/status.storage` projection against the LIVE root: the saved +// document resolves as storageStatus always did, but `dir`/`defaultDir` +// are the PER-ROOT directories the spawn actually uses (byte-identical for +// the default root; `-`-suffixed under the default root's .scratch +// for any other) — so the Storage card shows the path the daemon has. +const currentStorageStatus = () => { + const settings = effectiveStorage(); + return { + ...storageStatus(settings, defaultWorkspace, storageDefaults), + dir: + settings.persistence === "durable" + ? storeDirFor(settings, workspace, defaultWorkspace) + : "", + defaultDir: storeDirFor(storageDefaults, workspace, defaultWorkspace), + }; +}; +// The SAVED retention document (POST /retention), or null while mecated's +// own defaults apply (no flags passed) — the same null-means-unsaved shape +// as storageSettings, so a failed restart can roll back to "no file". +let retentionSettings = null; +const effectiveRetention = () => + retentionSettings ?? normalizeRetentionSettings({}); +// The `/status.retention` projection. An imported operator settings file +// suspends the flags entirely (its own retention: block, if any, then +// stands), which the UI reports as "operator-settings". +const retentionStatus = () => ({ + settings: effectiveRetention(), + managedBy: operatorSettingsActive ? "operator-settings" : "studio", +}); +// The saved daemon-defaults document (PUT /daemon-defaults, plus the +// active-provider choice POST /providers/active persists), loaded from +// daemonDefaultsFile before the first spawn; the empty document means every +// flag is omitted and the command line is byte-identical to the pre-feature +// one. Always the NORMALISED shape — never a raw body. +let daemonDefaults = DAEMON_DEFAULTS_EMPTY; +// A this-process-only trust grant: `--trust-project` on the next spawns +// without persisting it, for a "trust once" answer to a trust prompt. Never +// written to disk, so it dies with the controller — and ONLY with the +// controller: every daemon restart in between (a router save, a skill edit, +// a provider switch) keeps it, which the banner's copy says. +let trustOnce = false; +// The controller's OWN trust registry state for the CURRENT spawn (see +// src/lib/controller-trust.mjs), computed in startMecatl — never per /status +// poll, which would walk the workspace tree every 5 s. `trustDrifted` is +// the saved grant's anchor no longer matching the live one: the spawn then +// gets NO --trust-project (fail safe, the daemon's own Drifted arm) and the +// workspace banner re-prompts. Drift is detected at spawn time only. +let trustDrifted = false; +let trustAuthority = false; +let liveTrustAnchor = ""; +let startupLog = ""; +let restartQueue = Promise.resolve(); +let gatewayRefresh = null; +let shuttingDown = false; +let restartTimer = null; +let restartFailures = 0; +let startupError = ""; +const expectedExits = new WeakSet(); +const oauthAttempts = new Map(); +const oauthRedirectUri = "http://127.0.0.1:8788/oauth/callback"; +// The ToolHive LLM gateway reaches mecated through "thv llm proxy", a LOOPBACK +// reverse proxy that injects a fresh OIDC token per request. The controller +// never holds a gateway credential itself — that is the whole point of routing +// through the proxy rather than pasting a key. mecated auto-detects the same +// proxy from ToolHive's own config, so the port here is only used for the +// readiness probe that decides whether "toolhive" is an offerable provider. +const toolhiveGatewayURL = "http://127.0.0.1:14000/v1"; +let toolhiveReady = false; +// Whether ToolHive's `thv` CLI is on the controller's PATH — detected ONCE at +// boot with a FIXED command (no user input reaches the shell), so the +// provider inventory can offer to start the proxy (POST /toolhive/start) +// instead of only telling the operator to. Presence only; the resolved path +// is never reported. +let thvOnPath = false; +function detectThvOnPath() { + return new Promise((done) => { + execFile( + "/bin/sh", + ["-c", "command -v thv"], + { timeout: 2000 }, + (error, stdout) => done(!error && String(stdout).trim() !== ""), + ); + }); +} + +/** + * Whether `kind`'s documented credential env var is SET in this controller's + * environment — which the spawned mecated inherits, so the variable SHADOWS + * the auth.yaml key (the TUI's "configured (environment shadows …)" state). + * Presence only: the value is never read past the Boolean coercion, never + * logged, never sent. + */ +function providerEnvShadowed(kind) { + const name = PROVIDER_ENV_KEYS[kind]; + return name ? Boolean(process.env[name]) : false; +} + +/** + * The provider ids the RUNNING daemon lists models for (its ListModels view, + * where a provider with no resolved credentials is omitted — so "keyed in + * auth.yaml but absent here" means mecated started before the key landed and + * needs a restart). Null when the controller cannot tell: daemon down, on the + * offline mock (whose registry is the mock alone), or the read failed. A + * read-only inventory call; nothing about the key crosses it. + */ +async function daemonProviderIds() { + if (!child || !mecatlBaseURL || provider === "offline mock") return null; + try { + const response = await fetchMecatl("/v1/models", { + signal: AbortSignal.timeout(1500), + }); + if (!response.ok) return null; + const body = await response.json(); + const ids = new Set(); + for (const model of Array.isArray(body?.models) ? body.models : []) { + const id = model?.provider_id ?? model?.providerId; + if (typeof id === "string" && id !== "") ids.add(id); + } + return ids; + } catch { + return null; + } +} + +const delay = (milliseconds) => + new Promise((done) => setTimeout(done, milliseconds)); + +function jsonError(response, status, message) { + response.statusCode = status; + response.end(JSON.stringify({ error: message })); +} + +async function readBody(request, limit = 1_048_576) { + const declared = Number(request.headers["content-length"] || 0); + if (declared > limit) + throw Object.assign(new Error("request too large"), { statusCode: 413 }); + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > limit) + throw Object.assign(new Error("request too large"), { statusCode: 413 }); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +/** + * A REAL pipe for mecated's --lifetime-pipe-fd. The daemon fstat's the + * inherited descriptor and rejects anything that is not S_IFIFO — and Node's + * stdio "pipe" entries are AF_UNIX socketpairs — so the pipe is a named FIFO + * made with mkfifo(1), both ends opened, and the name unlinked (the + * descriptors outlive it). The controller holds the WRITE end and never + * writes: if this process dies — SIGKILL included — the kernel closes it, + * the child reads EOF, and mecated stops through its ordinary graceful + * shutdown. That is what keeps a controller crash from orphaning a daemon. + */ +async function createLifetimePipe() { + await rm(lifetimeFifoPath, { force: true }); + await new Promise((done, fail) => { + execFile("mkfifo", ["-m", "600", lifetimeFifoPath], (error) => + error ? fail(error) : done(), + ); + }); + try { + // Opening either end of a FIFO blocks until the other side opens, so the + // two opens must run concurrently; together they complete immediately. + const [readEnd, writeEnd] = await Promise.all([ + open(lifetimeFifoPath, "r"), + open(lifetimeFifoPath, "w"), + ]); + return { readEnd, writeEnd }; + } finally { + await rm(lifetimeFifoPath, { force: true }); + } +} + +/** + * Waits for THIS child's mecated-ready/1 document. The daemon publishes it + * atomically (temp + rename) and only after composition and every listener + * are up, so a successful read IS readiness — no connect polling, no + * stability window. A parse failure is a foreign file, never a torn write; + * a pid mismatch is the previous child's stale document (unlinked before the + * spawn, so only a pathological race shows one) and polling continues. + */ +async function waitForReadyDoc(proc) { + const deadline = Date.now() + readyWaitMs; + while (Date.now() < deadline) { + if (proc.exitCode !== null) + throw new Error(`mecatl exited during startup (code ${proc.exitCode})`); + if (child !== proc) + throw new Error("mecatl was replaced before it became ready"); + let text = ""; + try { + text = await readFile(readyFilePath, "utf8"); + } catch { + /* not published yet */ + } + if (text) { + let doc = null; + try { + doc = JSON.parse(text); + } catch { + /* not a ready document */ + } + if (doc && doc.schema !== "mecated-ready/1") + throw new Error( + `mecated wrote an unsupported ready-file schema "${doc.schema}"`, + ); + if (doc?.pid === proc.pid) { + if (typeof doc.http_address !== "string" || doc.http_address === "") + throw new Error("mecatl's ready file reports no HTTP listener"); + return doc; + } + } + await delay(100); + } + throw new Error("mecatl did not publish its ready file in time"); +} + +function fetchMecatl(path, options = {}) { + if (!mecatlBaseURL) + throw new Error("mecated has not been assigned a listener yet"); + const headers = new Headers(options.headers); + headers.set("authorization", `Bearer ${managedAuthToken}`); + return fetch(new URL(path, mecatlBaseURL), { ...options, headers }); +} + +function providerLabel(kind) { + if (kind === "mock") return "offline mock"; + if (kind === "toolhive") return "ToolHive LLM gateway"; + return ( + KNOWN_AUTH_PROVIDERS.find((entry) => entry.name === kind)?.label ?? kind + ); +} + +function startupFailure(kind, message) { + startupError = + kind !== "mock" && kind !== "toolhive" + ? `${providerLabel(kind)} could not start. Add providers.${kind}.api_key to ${authFile}, then switch to it again. mecated: ${message}` + : message; + // Into the log file too (not echoed — every caller surfaces it), so a + // crash-at-start is diagnosable from the file alone. + recordDaemonLog(`[supervisor] ${startupError}\n`); + return new Error(startupError); +} + +// A short probe, deliberately: when no token is cached the proxy blocks on an +// interactive browser login, and a controller start must never hang on that. +// Timing out simply means "not offerable right now" and studio falls back. +async function detectToolhiveGateway() { + try { + const response = await fetch(`${toolhiveGatewayURL}/models`, { + signal: AbortSignal.timeout(2500), + }); + return response.ok; + } catch { + return false; + } +} + +// Provider credentials are owned by mecated's conventional auth file, never +// copied through a browser form or patched into the child's environment here. +// MECATL_STUDIO_PROVIDER / the /providers/active switch select a provider +// without carrying its credential. +// The ToolHive gateway is offerable only while it answers AND the saved +// daemon defaults have not passed `--toolhive-llm=false` (a disabled +// detection means mecated never registers the provider, so naming it as the +// default would fail startup). +const toolhiveOfferable = () => + toolhiveReady && daemonDefaults.toolhive.enabled; +const preferredKind = () => + activeProviderOverride || (toolhiveOfferable() ? "toolhive" : "mock"); + +/** Whether `kind` is safe to hand to startMecatl right now: the two + * synthetic kinds (mock always, toolhive only while the gateway answers and + * detection is not disabled), a provider that actually has a block in + * auth.yaml, or a settings-defined custom provider (ADR 0238) — never an + * arbitrary string, so a typo can't reach mecated's fail-fast + * --default-provider check and crash the child. */ +function isSelectableProviderKind(kind, selectableNames) { + if (kind === "mock") return true; + if (kind === "toolhive") return toolhiveOfferable(); + return selectableNames.includes(kind); +} + +function normalizeModelRouter(input) { + const classifierModel = + typeof input?.classifierModel === "string" + ? input.classifierModel.trim() + : ""; + if (!classifierModel || classifierModel.length > 200) + throw new Error("Choose a valid classifier model"); + if ( + !Array.isArray(input?.categories) || + input.categories.length < 2 || + input.categories.length > 8 + ) { + throw new Error("Semantic routing needs between 2 and 8 categories"); + } + const seen = new Set(); + const categories = input.categories.map((category) => { + const name = + typeof category?.name === "string" + ? category.name.trim().toLowerCase() + : ""; + const description = + typeof category?.description === "string" + ? category.description.trim() + : ""; + const model = + typeof category?.model === "string" ? category.model.trim() : ""; + if (!/^[a-z][a-z0-9_-]{0,39}$/.test(name)) + throw new Error( + "Category names must start with a letter and use only letters, numbers, underscores, or dashes", + ); + if (seen.has(name)) throw new Error(`Category name ${name} is duplicated`); + if (!description || description.length > 300) + throw new Error( + `Category ${name} needs a distinct description of at most 300 characters`, + ); + if (!model || model.length > 200) + throw new Error(`Choose a model for category ${name}`); + seen.add(name); + return { name, description, model }; + }); + const defaultCategory = + typeof input?.defaultCategory === "string" + ? input.defaultCategory.trim().toLowerCase() + : ""; + if (!seen.has(defaultCategory)) + throw new Error( + "The default category must match one of the routing categories", + ); + return { + enabled: input?.enabled !== false, + classifierModel, + defaultCategory, + categories, + }; +} + +const yamlString = (value) => JSON.stringify(String(value)); + +function renderModelRouterYAML(config) { + const categories = config.categories + .map((category) => + [ + ` - name: ${yamlString(category.name)}`, + ` description: ${yamlString(category.description)}`, + ` model: ${yamlString(category.model)}`, + ].join("\n"), + ) + .join("\n"); + return [ + "# Managed by Mecatl Studio. This is loaded at the trusted CLI/operator tier.", + "models:", + " slots:", + ` router: ${yamlString(config.classifierModel)}`, + " router:", + ` disabled: ${config.enabled ? "false" : "true"}`, + " classifier-slot: router", + ` default-category: ${yamlString(config.defaultCategory)}`, + " categories:", + categories, + "", + ].join("\n"); +} + +async function persistModelRouter(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const yamlTemp = `${routerSettingsFile}.tmp`; + const jsonTemp = `${routerStateFile}.tmp`; + await writeFile(yamlTemp, renderModelRouterYAML(config), { mode: 0o600 }); + await writeFile(jsonTemp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(yamlTemp, routerSettingsFile); + await rename(jsonTemp, routerStateFile); +} + +async function loadModelRouter() { + try { + return normalizeModelRouter( + JSON.parse(await readFile(routerStateFile, "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[router] saved configuration ignored: ${error.message || error}\n`, + ); + return null; + } +} + +/** + * The operator-tier `learning:` block and `steer:` scalar mecated will + * itself capture, mirroring its first-file-wins order: the imported + * operator-settings.yaml (a CLI-tier file) when active, then the user-global + * settings.yaml. What the rendered runtime-settings file merges over, and + * what GET /runtime-settings reports as inherited. + */ +async function readInheritedRuntimeSettings() { + const sources = operatorSettingsActive + ? [operatorSettingsFile, userSettingsFile] + : [userSettingsFile]; + let learning = null; + let steer = null; + for (const file of sources) { + let text; + try { + text = await readFile(file, "utf8"); + } catch { + continue; + } + if (learning === null) learning = readLearningBlock(text); + if (steer === null) steer = readSteerScalar(text); + } + return { + learning: learning ?? NO_INHERITED_SETTINGS.learning, + steer, + }; +} + +/** Atomic (tmp + rename), owner-only: the JSON document only. The YAML + * mecated reads is rendered fresh by every spawn (writeRuntimeSettingsYAML) + * so a later edit of the user-global learning block is still merged in. */ +async function persistRuntimeState(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${runtimeStateFile}.tmp`; + await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, runtimeStateFile); +} + +/** The CLI-tier learning file for THIS spawn: Studio's mode/sensitivity + * merged over the inherited block. Atomic, owner-only. */ +async function writeRuntimeSettingsYAML(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const inherited = await readInheritedRuntimeSettings(); + const temp = `${runtimeSettingsFile}.tmp`; + await writeFile(temp, renderRuntimeSettingsYAML(config, inherited), { + mode: 0o600, + }); + await rename(temp, runtimeSettingsFile); +} + +/** The saved runtime settings, or the defaults when none were saved yet. A + * corrupt file is logged and ignored, never honoured. */ +async function loadRuntimeSettings() { + try { + return normalizeRuntimeSettings( + JSON.parse(await readFile(runtimeStateFile, "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[runtime-settings] saved configuration ignored: ${error.message || error}\n`, + ); + return DEFAULT_RUNTIME_SETTINGS; + } +} + +/** Atomic (tmp + rename), owner-only. Flags only — no settings.yaml key. */ +async function persistDaemonOptions(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${daemonOptionsFile}.tmp`; + await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, daemonOptionsFile); +} + +/** The saved daemon options, or the defaults when none were saved yet. A + * corrupt file is logged and ignored, never honoured. */ +async function loadDaemonOptions() { + try { + return normalizeDaemonOptions( + JSON.parse(await readFile(daemonOptionsFile, "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[daemon-options] saved configuration ignored: ${error.message || error}\n`, + ); + return DEFAULT_DAEMON_OPTIONS; + } +} + +/** + * The filesystem half of the daemon-options directory check (the shape half + * is normalizeOptionDir): every SET directory, resolved against the live + * root, must sit under one of optionDirRoots() lexically AND after + * realpath() of its deepest existing ancestor (a symlinked parent cannot + * escape; the directory itself may not exist yet — the spawn creates the + * skills/memory ones), and an existing path must be a real directory, not a + * symlink or a file. Throws a 400-shaped error naming the field. + */ +async function validateOptionDirs(config) { + const bad = (message) => + Object.assign(new Error(message), { statusCode: 400 }); + const roots = optionDirRoots(); + const realRoots = await Promise.all( + roots.map((root) => realpath(root).catch(() => root)), + ); + for (const [field, dir] of daemonOptionDirFields(config)) { + const path = resolveOptionDir(dir, workspace); + if (!optionDirWithinRoots(path, roots)) + throw bad( + `${field} must be inside the workspace ${workspace}${ + defaultWorkspace !== workspace ? `, ${defaultWorkspace}` : "" + } or ${mecatlConfigDir}`, + ); + let probe = path; + let info = null; + while (probe !== "/") { + try { + info = await lstat(probe); + break; + } catch { + probe = dirname(probe); + } + } + if (info && probe === path) { + if (info.isSymbolicLink()) throw bad(`${field} must not be a symlink`); + if (!info.isDirectory()) throw bad(`${field} must be a directory`); + } + const real = await realpath(probe).catch(() => ""); + const realPath = real ? `${real}${path.slice(probe.length)}` : ""; + if (!realPath || !optionDirWithinRoots(realPath, realRoots)) + throw bad(`${field} resolves outside the allowed directories`); + } +} + +/** The GET /daemon-options body: the saved document, the locations it + * falls back to (the form's placeholders), the directories THIS spawn + * resolved to, and where they may sit. Paths on this machine, never + * contents. */ +function daemonOptionsDocument() { + const context = daemonOptionContext(); + return { + options: daemonOptions, + defaults: { + skillsDir: context.pinnedSkillsDir, + memoryDir: context.defaultMemoryDir, + userModelDir: defaultUserModelDir, + commandDirs: DEFAULT_COMMAND_DIRS, + }, + effective: effectiveDaemonDirs(daemonOptions, context), + allowedRoots: optionDirRoots(), + }; +} + +/** The `/status` mirror: the switches the CURRENT child was spawned with, + * read-only, one poll for the UI. */ +function daemonOptionsStatus() { + return { + projectMemory: daemonOptions.projectMemory.enabled, + userModel: daemonOptions.userModel.enabled, + userModelReviewInterval: daemonOptions.userModel.reviewInterval, + skills: daemonOptions.skills.enabled, + commands: daemonOptions.commands.enabled, + toolhive: daemonOptions.mcp.toolhive, + toolhiveGroup: daemonOptions.mcp.toolhiveGroup, + mcpResourceTools: daemonOptions.mcp.resourceTools, + mcpPrompts: daemonOptions.mcp.prompts, + }; +} + +/** + * The filesystem half of the soul-path check (the shape half is + * normalizeSoulFile): the path must sit under the mecatl config dir or the + * workspace BOTH lexically and after realpath() (a symlinked parent cannot + * escape), must itself be a regular file rather than a symlink (mecatui's + * rejectSymlinkPath discipline), must fit the daemon's 20 KiB ceiling + * (over it mecated rejects the soul outright, so the save would "succeed" + * into a persona-less daemon), and must be readable. Throws a 400-shaped + * error naming the reason. + */ +async function validateSoulFile(path) { + const bad = (message) => + Object.assign(new Error(message), { statusCode: 400 }); + if (!soulFileWithinRoots(path, soulFileRoots())) + throw bad( + `soul.file must be inside ${mecatlConfigDir} or the workspace ${workspace}`, + ); + let info; + try { + info = await lstat(path); + } catch { + throw bad(`soul.file does not exist: ${path}`); + } + if (info.isSymbolicLink()) throw bad("soul.file must not be a symlink"); + if (!info.isFile()) throw bad("soul.file must be a regular file"); + if (info.size > SOUL_MAX_BYTES) + throw bad( + `soul.file is ${info.size} bytes; mecated rejects a soul over ${SOUL_MAX_BYTES} bytes`, + ); + const real = await realpath(path).catch(() => ""); + const realRoots = await Promise.all( + soulFileRoots().map((root) => realpath(root).catch(() => root)), + ); + if (!real || !soulFileWithinRoots(real, realRoots)) + throw bad("soul.file resolves outside the allowed directories"); + try { + await readFile(path, "utf8"); + } catch (error) { + throw bad(`soul.file is not readable: ${error.message || error}`); + } +} + +/** The `*.md` regular files directly inside the mecatl config dir (dotfiles + * and symlinks skipped, at most 50, sorted) — the picker's candidates, so + * a user need not type a path. Absolute paths on the operator's OWN + * machine (managed mode only), never file contents. */ +async function listSoulCandidates() { + let entries; + try { + entries = await readdir(mecatlConfigDir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter( + (entry) => + entry.isFile() && + !entry.name.startsWith(".") && + entry.name.endsWith(".md") && + entry.name !== ".md", + ) + .map((entry) => ({ + path: resolve(mecatlConfigDir, entry.name), + name: basename(entry.name, ".md"), + })) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, 50); +} + +/** The GET /runtime-settings body: the saved document, who manages each + * knob, the inherited settings.yaml values, the fold mecated will actually + * run with, and the soul picker's inputs. */ +async function runtimeSettingsDocument() { + const inherited = await readInheritedRuntimeSettings(); + return { + config: runtimeSettings, + managedBy: { + learning: operatorSettingsActive ? "operator-settings" : "studio", + steer: "studio", + soul: "studio", + }, + inherited: { + learning: { + mode: inherited.learning.mode, + sensitivity: inherited.learning.sensitivity, + }, + steer: inherited.steer, + }, + effective: effectiveRuntimeSettings(runtimeSettings, inherited, { + operatorSettingsActive, + }), + soulFileDefault: userSoulFile, + soulCandidates: await listSoulCandidates(), + }; +} + +/** Atomic (tmp + rename), owner-only. Flags only — no settings.yaml key. */ +async function persistPermissions(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${permissionsStateFile}.tmp`; + await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, permissionsStateFile); +} + +/** The saved permissions, or mecated's defaults when none were saved yet. A + * corrupt or unknown-posture file is logged and ignored, never honoured. */ +async function loadPermissions() { + try { + return normalizePermissions( + JSON.parse(await readFile(permissionsStateFile, "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[permissions] saved configuration ignored: ${error.message || error}\n`, + ); + return normalizePermissions({}); + } +} + +/** + * Persists a permissions document and restarts the daemon on its flags, + * serialised on the restart queue. A start mecated refuses (auto/yolo as + * root outside MECATL_SANDBOX) rolls the previous document back, restarts + * on it, and rethrows with mecated's own refusal so the caller can answer + * it as the 400 body. Shared by POST /permissions and the two trust grants. + * @param {ReturnType} next + */ +function restartOnPermissions(next) { + return queueRestart(async () => { + const previous = permissionsConfig; + permissionsConfig = next; + await persistPermissions(next); + try { + await startMecatl(preferredKind()); + } catch (error) { + // mecated names the refused tier on stderr; the generic + // "exited during startup" alone would hide the reason. + const refusal = startupLog.match(/posture "[a-z]+" refused[^\n]*/); + permissionsConfig = previous; + await persistPermissions(previous); + await startMecatl(preferredKind()); + throw refusal + ? new Error(`${refusal[0]} (previous permissions restored)`) + : error; + } + }); +} + +/** Atomic (tmp + rename), owner-only. Flags only — no settings.yaml key. */ +async function persistDiagnosticsOptions(options) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${diagnosticsOptionsFile}.tmp`; + await writeFile(temp, `${JSON.stringify(options, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, diagnosticsOptionsFile); +} + +/** The saved diagnostics options, or the defaults when none were saved yet. + * A corrupt file is logged and ignored, never honoured. */ +async function loadDiagnosticsOptions() { + try { + return normalizeDiagnosticsOptions( + JSON.parse(await readFile(diagnosticsOptionsFile, "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[diagnostics] saved options ignored: ${error.message || error}\n`, + ); + return normalizeDiagnosticsOptions({}); + } +} + +// Bytes in the current log generation (null until the first write stat's +// the file), and the ONE promise chain every append rides so chunk order +// and the rotate-then-append step hold under a chatty daemon. +let daemonLogSize = null; +let daemonLogQueue = Promise.resolve(); +let daemonLogWriteWarned = false; + +/** + * Records one chunk of daemon diagnostics: into the in-memory ring at once, + * and onto the owner-only file through the serialised queue — rotating the + * current generation to `mecated.log.1` (overwriting the previous one) when + * the append would push it past the 10 MiB bound. A file that cannot be + * written is reported ONCE and never takes the relay down: the ring and + * the terminal mirror keep working without it. + */ +function recordDaemonLog(text) { + if (typeof text !== "string" || text === "") return; + logRing.append(text); + const bytes = Buffer.byteLength(text); + daemonLogQueue = daemonLogQueue + .then(async () => { + if (daemonLogSize === null) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + daemonLogSize = await stat(mecatedLogFile).then( + (info) => info.size, + () => 0, + ); + } + if (shouldRotate(daemonLogSize, bytes)) { + await rename(mecatedLogFile, rotatedLogFile); + daemonLogSize = 0; + } + await appendFile(mecatedLogFile, text, { mode: 0o600 }); + daemonLogSize += bytes; + }) + .catch((error) => { + daemonLogSize = null; + if (daemonLogWriteWarned) return; + daemonLogWriteWarned = true; + process.stderr.write( + `[supervisor] daemon log file unavailable (${error.message || error}); continuing without it\n`, + ); + }); +} + +/** The controller's own supervisor lines: always echoed (they are not the + * mecated relay `quiet` mutes) AND recorded in the same file, so a restart + * loop reads in order next to the daemon's last words. */ +function supervisorNote(line) { + process.stderr.write(line); + recordDaemonLog(line); +} + +/** The `GET /logs` payload: file facts, the bounded tail, the two + * controller-side knobs the card edits, liveness and the last startup + * error. Paths and text only — nothing here is a credential. */ +async function daemonLogStatus(lines) { + const sizeBytes = await stat(mecatedLogFile).then( + (info) => info.size, + () => 0, + ); + return { + path: mecatedLogFile, + rotatedPath: rotatedLogFile, + sizeBytes, + maxBytes: MAX_LOG_FILE_BYTES, + lines: logRing.lines(lines), + truncated: tailIsTruncated(logRing, lines), + quiet: diagnosticsOptions.quiet, + level: diagnosticsOptions.logLevel, + running: Boolean(child), + startupError, + }; +} + +/** The effective product-metrics verdict: the saved switch AND no opt-out + * in the controller's own environment, which the spawned mecated inherits. + * mecated's precedence is flag > MECATL_PRODUCT_METRICS > DO_NOT_TRACK > + * settings.yaml (internal/cliconfig ResolveProductMetricsEnabled), so an + * explicit --product-metrics=true WOULD out-rank the environment — which + * is exactly why the controller never passes it (only =false when the + * switch is off): the operator's env opt-out always wins. The settings.yaml + * tier is not readable here, so `enabled: true` means "not disabled by + * Studio or this environment", not a definitive on. */ +function diagnosticsStatus() { + const envOptOut = productMetricsEnvOptOut(process.env); + return { + options: diagnosticsOptions, + productMetrics: { + enabled: !envOptOut && diagnosticsOptions.productMetrics.enabled, + dryRun: diagnosticsOptions.productMetrics.dryRun, + source: envOptOut ? "environment" : "studio", + }, + }; +} + +/** Atomic (tmp + rename), owner-only. Flags only — no settings.yaml key. */ +async function persistDaemonDefaults(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${daemonDefaultsFile}.tmp`; + await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, daemonDefaultsFile); +} + +/** The saved daemon defaults, or the empty document when none were saved. + * A corrupt or out-of-grammar file is logged and ignored, never honoured + * (a stale --default-model would otherwise wedge every restart). */ +async function loadDaemonDefaults() { + try { + return normalizeDaemonDefaults( + JSON.parse(await readFile(daemonDefaultsFile, "utf8")), + { configDir: mecatlConfigDir }, + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[daemon-defaults] saved configuration ignored: ${error.message || error}\n`, + ); + return DAEMON_DEFAULTS_EMPTY; + } +} + +/** Makes `next` the live document: the effective credentials file follows + * its `apiKeyFile` so every auth.yaml route reads what the daemon reads. */ +function applyDaemonDefaults(next) { + daemonDefaults = next; + authFile = next.apiKeyFile || defaultAuthFile; +} + +/** Atomic (tmp + rename), owner-only. A spawn flag — no settings.yaml key. */ +async function persistStorageSettings(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${storageStateFile}.tmp`; + await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, storageStateFile); +} + +/** The saved storage document, or null when none was saved yet (the env / + * built-in defaults then apply). A corrupt file is logged and ignored. */ +async function loadStorageSettings() { + try { + return normalizeStorageSettings( + JSON.parse(await readFile(storageStateFile, "utf8")), + storageDefaults, + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[storage] saved configuration ignored: ${error.message || error}\n`, + ); + return null; + } +} + +/** The saved workspace root: atomic (tmp + rename), owner-only. The DEFAULT + * root writes no file — the state dir stays clean until a root is chosen, + * and a rollback to the default removes the document again. */ +async function persistWorkspace(root) { + if (root === defaultWorkspace) { + await rm(workspaceStateFile, { force: true }); + return; + } + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${workspaceStateFile}.tmp`; + await writeFile(temp, `${JSON.stringify({ workspace: root }, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, workspaceStateFile); +} + +/** The saved workspace root, or the default when none was saved. A corrupt + * document falls back to the default (normalizeWorkspaceConfig); a saved + * root that no longer exists / is no longer a directory is logged and + * ALSO falls back — mecated would otherwise fail every start against it. */ +async function loadWorkspace() { + let saved; + try { + saved = normalizeWorkspaceConfig( + JSON.parse(await readFile(workspaceStateFile, "utf8")), + defaultWorkspace, + ).workspace; + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[workspace] saved root ignored: ${error.message || error}\n`, + ); + return defaultWorkspace; + } + if (saved === defaultWorkspace) return defaultWorkspace; + try { + return await validateWorkspacePath(saved, { + realpath, + stat, + defaultWorkspace, + forbidden: [studioStateDir], + }); + } catch (error) { + process.stderr.write( + `[workspace] saved root ${saved} ignored, using the default: ${error.message || error}\n`, + ); + return defaultWorkspace; + } +} + +/** Atomic (tmp + rename), owner-only. Spawn flags — no settings.yaml key. */ +async function persistRetentionSettings(config) { + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + const temp = `${retentionStateFile}.tmp`; + await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temp, retentionStateFile); +} + +/** The saved retention document, or null when none was saved yet (mecated's + * own defaults then apply). A corrupt file — including one that enables + * main deletion without the acknowledgement — is logged and ignored. */ +async function loadRetentionSettings() { + try { + return normalizeRetentionSettings( + JSON.parse(await readFile(retentionStateFile, "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[retention] saved configuration ignored: ${error.message || error}\n`, + ); + return null; + } +} + +async function hasOperatorSettings() { + try { + await readFile(operatorSettingsFile, "utf8"); + return true; + } catch (error) { + if (error?.code !== "ENOENT") + process.stderr.write( + `[settings] operator configuration ignored: ${error.message || error}\n`, + ); + return false; + } +} + +function wellKnownURL(base, name) { + const url = new URL(base); + const issuerPath = + url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + return new URL(`/.well-known/${name}${issuerPath}`, url.origin).toString(); +} + +async function fetchJSON(url) { + try { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + redirect: "follow", + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +function requireHttpsEndpoint(value, label) { + if (!value) throw new Error(`OAuth metadata does not include ${label}`); + const endpoint = new URL(value); + if (endpoint.protocol !== "https:") + throw new Error(`OAuth ${label} must use HTTPS`); + return endpoint.toString(); +} + +async function discoverOAuth(gatewayURL) { + const resourceCandidates = []; + try { + const probe = await fetch(gatewayURL, { + method: "GET", + headers: { Accept: "text/event-stream" }, + redirect: "manual", + signal: AbortSignal.timeout(5_000), + }); + const challenge = probe.headers.get("www-authenticate") || ""; + const match = challenge.match( + /resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i, + ); + if (match?.[1] || match?.[2]) resourceCandidates.push(match[1] || match[2]); + await probe.body?.cancel().catch(() => undefined); + } catch { + /* fall through to RFC 9728 well-known locations */ + } + + resourceCandidates.push( + wellKnownURL(gatewayURL, "oauth-protected-resource"), + new URL( + "/.well-known/oauth-protected-resource", + gatewayURL.origin, + ).toString(), + ); + + let resourceMetadata = null; + for (const candidate of [...new Set(resourceCandidates)]) { + let candidateURL; + try { + candidateURL = requireHttpsEndpoint( + candidate, + "protected resource metadata URL", + ); + } catch { + continue; + } + resourceMetadata = await fetchJSON(candidateURL); + if (resourceMetadata) break; + } + + const authorizationServer = + resourceMetadata?.authorization_servers?.[0] || gatewayURL.origin; + const metadataCandidates = [ + wellKnownURL(authorizationServer, "oauth-authorization-server"), + wellKnownURL(authorizationServer, "openid-configuration"), + new URL( + "/.well-known/openid-configuration", + new URL(authorizationServer).origin, + ).toString(), + ]; + let metadata = null; + for (const candidate of [...new Set(metadataCandidates)]) { + metadata = await fetchJSON(candidate); + if (metadata) break; + } + if (!metadata) + throw new Error( + "The gateway did not advertise usable MCP OAuth authorization-server metadata", + ); + + const authorizationEndpoint = requireHttpsEndpoint( + metadata.authorization_endpoint, + "authorization endpoint", + ); + const tokenEndpoint = requireHttpsEndpoint( + metadata.token_endpoint, + "token endpoint", + ); + const registrationEndpoint = requireHttpsEndpoint( + metadata.registration_endpoint, + "dynamic client registration endpoint", + ); + const resourceScopes = Array.isArray(resourceMetadata?.scopes_supported) + ? resourceMetadata.scopes_supported + : []; + const authScopes = Array.isArray(metadata.scopes_supported) + ? metadata.scopes_supported + : []; + const defaultScopes = ["openid", "profile", "email", "offline_access"]; + const preferredAuthScopes = defaultScopes.filter((scope) => + authScopes.includes(scope), + ); + const scopes = [...new Set([...resourceScopes, ...preferredAuthScopes])]; + + return { + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint, + resource: resourceMetadata?.resource || gatewayURL.toString(), + scope: scopes.join(" ") || "openid profile email offline_access", + }; +} + +function queueRestart(operation) { + const run = restartQueue.then(operation, operation); + restartQueue = run.catch(() => undefined); + return run; +} + +function scheduleMecatlRestart() { + if (shuttingDown || restartTimer || child) return; + const wait = Math.min(10_000, 750 * 2 ** restartFailures); + supervisorNote( + `[supervisor] mecated stopped unexpectedly; restarting in ${wait}ms\n`, + ); + restartTimer = setTimeout(() => { + restartTimer = null; + queueRestart(async () => { + if (shuttingDown || child) return; + try { + await startMecatl(preferredKind()); + restartFailures = 0; + supervisorNote("[supervisor] mecated restarted\n"); + } catch (error) { + restartFailures += 1; + supervisorNote( + `[supervisor] restart failed: ${error.message || error}\n`, + ); + scheduleMecatlRestart(); + } + }); + }, wait); +} + +async function refreshGatewayAccessToken(force = false) { + if (!gateway?.refreshToken || !gateway.clientId || !gateway.tokenEndpoint) + return false; + if (!force && gateway.expiresAt && gateway.expiresAt > Date.now() + 60_000) + return true; + if (gatewayRefresh) return gatewayRefresh; + gatewayRefresh = (async () => { + // No `scope` on refresh: RFC 6749 §6 makes it optional (the server reuses + // the originally granted scopes), and this provider rejects the request as + // malformed when it is present — which silently killed every auto-refresh + // and made gateway auth die on the hour. + const refreshBody = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: gateway.refreshToken, + client_id: gateway.clientId, + }); + if (gateway.resource) refreshBody.set("resource", gateway.resource); + const tokenResponse = await fetch(gateway.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: refreshBody, + }); + const tokenResult = await tokenResponse.json().catch(() => ({})); + if (!tokenResponse.ok || !tokenResult.access_token) { + process.stderr.write( + `[oauth] refresh rejected (${tokenResponse.status}, ${String(tokenResult.error || "unknown_error").slice(0, 80)})\n`, + ); + throw new Error( + tokenResult.error_description || + tokenResult.error || + "Gateway token refresh failed", + ); + } + gateway.token = tokenResult.access_token; + if (tokenResult.refresh_token) + gateway.refreshToken = tokenResult.refresh_token; + gateway.expiresAt = + Date.now() + Math.max(60, Number(tokenResult.expires_in) || 300) * 1000; + process.stderr.write("[oauth] gateway access token refreshed\n"); + return true; + })().finally(() => { + gatewayRefresh = null; + }); + return gatewayRefresh; +} + +async function stopChild() { + if (!child) { + await delay(200); + return; + } + const current = child; + expectedExits.add(current); + await new Promise((done) => { + const timer = setTimeout(() => { + current.kill("SIGKILL"); + done(); + }, 2000); + current.once("exit", () => { + clearTimeout(timer); + done(); + }); + current.kill("SIGTERM"); + }); + if (child === current) child = null; + // Let loopback listeners finish closing before the next child binds them. + await delay(250); +} + +// `adminRetry` is internal: set on the ONE re-spawn startMecatl allows itself +// when the probed admin port was taken before mecated could bind it. +async function startMecatl(kind, { adminRetry = false } = {}) { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } + await stopChild(); + startupLog = ""; + startupError = ""; + // No listener is assigned until THIS child's ready file names one: the old + // base URL points at a dead port, and fetchMecatl's guard is the honest + // answer while the restart is in flight. + mecatlBaseURL = ""; + readyInfo = null; + await mkdir(studioStateDir, { recursive: true, mode: 0o700 }); + // The daemon never removes its ready file (a SIGKILLed one could not), so + // the previous child's document must go before the spawn — otherwise the + // wait below could read yesterday's addresses. The pid check is the + // backstop for the pathological race. + await rm(readyFilePath, { force: true }); + // Session store: `--store-dir ` for a durable store (the directory + // created first — mecated does not create it), NO flag for in-memory + // (mecated has no --no-store; an empty --store-dir IS the in-memory store). + // A path mkdir refuses fails this start, which the POST /storage rollback + // turns into the 400 body and a restart on the previous document. + // + // Both are PER-ROOT (src/lib/workspace-config.mjs): a non-default root's + // store is `/.scratch/studio-sessions-` — a session is + // bound to the placement it was created in (ADR 0291), so a shared store + // would list chats that cannot run — and the default root keeps the + // historical path byte for byte. + const storage = effectiveStorage(); + if (storage.persistence === "durable") + await mkdir(storeDirFor(storage, workspace, defaultWorkspace), { + recursive: true, + }); + const args = [ + "serve", + "--workspace", + workspace, + ...storageArgsFor(storage, workspace, defaultWorkspace), + "--grpc-addr", + "127.0.0.1:0", + // An ephemeral HTTP port: the kernel picks it at bind(2) time and the + // ready file reports it RESOLVED — no pre-bind pick, no TOCTOU window. + "--http-addr", + "127.0.0.1:0", + "--ready-file", + readyFilePath, + ]; + if (operatorSettingsActive) + args.push("--permission-config", operatorSettingsFile); + else if (modelRouterConfig) + args.push("--permission-config", routerSettingsFile); + // The learning mode/sensitivity file (--permission-config is repeatable; + // its `learning:` block does not overlap the router file's `models:`), + // rendered fresh for THIS spawn over the user-global learning block, and + // only when the document overrides something. Not alongside an imported + // operator settings file: that file's own learning: block then stands. + if (!operatorSettingsActive && runtimeSettingsHasYAML(runtimeSettings)) { + await writeRuntimeSettingsYAML(runtimeSettings); + args.push("--permission-config", runtimeSettingsFile); + } + // The daemon options as spawn flags (src/lib/daemon-options.mjs): the + // skills/memory directories (mecated refuses to start on a missing + // --skills-dir, and an empty directory is the correct "no skills yet" + // state, so each ENABLED store's directory is created before every spawn + // — a disabled one gets neither the mkdir nor the flag, which is how + // mecated turns Remember/Recall and the Skill tool off), the user-model + // flags, slash commands and MCP discovery. The default document renders + // the pre-feature command line byte for byte. Every directory passed the + // containment check at save time (validateOptionDirs). + const optionDirs = effectiveDaemonDirs(daemonOptions, daemonOptionContext()); + if (daemonOptions.skills.enabled) + await mkdir(optionDirs.skillsDir, { recursive: true }); + if (daemonOptions.projectMemory.enabled) + await mkdir(optionDirs.memoryDir, { recursive: true }); + if (daemonOptions.userModel.enabled && optionDirs.userModelDir) + await mkdir(optionDirs.userModelDir, { recursive: true }); + if (daemonOptions.commands.enabled && optionDirs.commandsDir) + await mkdir(optionDirs.commandsDir, { recursive: true }); + args.push(...daemonOptionArgs(daemonOptions, daemonOptionContext())); + // The saved daemon defaults as spawn flags: the model pair is emitted for + // THIS kind only (mecated validates --default-model against the current + // default provider fail-fast), the rest is global. An empty document adds + // nothing, so the pre-feature command line is byte-identical. + args.push(...daemonDefaultArgs(daemonDefaults, kind)); + // Operator posture / project trust / shell-less mode as CLI flags. This + // spawn is deliberately NOT --headless: on an interactive root mecated + // raises the project-trust floor for trusted/auto/yolo, which is what the + // Permissions page tells the user (controller-permissions.test.ts pins it). + // + // The controller's own trust registry is re-read here, once per spawn: + // the live identity anchor decides whether a SAVED grant still stands + // (its stamped anchor must equal the live one — a soul/agent/command/ + // skill edit since the grant drifts it, and a drifted grant passes NO + // trust flag), and the authority probe tells /status whether there is + // anything a grant would admit at all. + liveTrustAnchor = trustAnchor(workspace); + trustAuthority = hasProjectAuthority(workspace); + trustDrifted = + permissionsConfig.trustProject && + permissionsConfig.trustAnchor !== liveTrustAnchor; + args.push(...permissionArgs(permissionsConfig, { trustOnce, trustDrifted })); + // The managed daemon is a private loopback spawn for one user, so it gets + // the embedded-server storage grant: Settings → Storage reads health and + // cleans up old runs through the daemon's own routes (capabilities + // storage_health / storage_cleanup). External deployments decide this + // themselves (storage_management.principals). + args.push("--local-storage-management"); + // Observability flags: log level, the admin/metrics listener, the perf + // MCP mount, the goroutine alarm, product-metrics opt-out. `quiet` is + // controller-side (the stderr mirror below) and adds no flag. + // + // The admin listener is EXPLICIT in both directions. Off (the default) + // passes `--metrics-addr=`: mecated's built-in default is a FIXED + // loopback port (127.0.0.1:9090) that two managed daemons on one machine + // would fight over and that an "off" switch would otherwise silently + // leave open — so a managed daemon opens NO admin listener until the + // Performance card turns it on (anyone who scraped :9090 from a managed + // Studio daemon before this flag was passed enables the surface there). + // On, the controller CHOOSES the port: the ready file names only + // http_address, so a free loopback port is probed and released just + // before the spawn. Probe-to-bind is a real, rare race; the ready-wait + // below re-probes and re-spawns ONCE when the failed start says the + // address was taken. + adminAddr = diagnosticsOptions.admin.enabled + ? `127.0.0.1:${await freeLoopbackPort()}` + : ""; + args.push(...diagnosticsOptionArgs(diagnosticsOptions, { adminAddr })); + // Retention limits + sweep cadence as CLI flags (they out-rank a settings + // file per field), but ONLY when no imported operator settings file is + // active: that file's own retention: block, if any, then stands untouched + // and POST /retention is refused. + if (!operatorSettingsActive) + args.push(...retentionArgs(effectiveRetention())); + // The steer opt-out and the soul flags (--no-steer, --no-soul, + // --soul-strict, --soul-file; --approve-soul for the one spawn POST + // /soul/approve asks for). CLI flags out-rank any settings file, so they + // hold under an imported operator file too — and --no-steer can only + // tighten (steer is on by default; the file's own steer: false stands). + args.push( + ...runtimeSettingsArgs(runtimeSettings, { + approveSoul: approveSoulPending, + }), + ); + if (kind === "mock") { + args.push("--mock"); + } else if (kind === "toolhive") { + // No credential and no base-URL flag for the gateway: mecated finds the + // loopback proxy through ToolHive's own config and registers it as the + // "toolhive" provider on its own. Naming it as the default is all it takes. + args.push("--default-provider", "toolhive"); + } else { + // Any auth.yaml-configured provider (openrouter, anthropic, openai, + // opencode, …) or a settings-defined custom provider (ADR 0238) — + // mecated validates the id fail-fast at startup. + args.push("--default-provider", kind); + } + const env = { ...process.env, MECATL_AUTH_TOKEN: managedAuthToken }; + if (gateway) { + // Mecatl's SDK opens the optional standalone SSE notification stream after + // initialization. Some authenticated gateways (including Connector Gateway) + // close that GET stream and thereby cancel an otherwise valid MCP session. + // Keep the optional stream on loopback and forward request/response traffic. + args.push( + "--mcp-server", + `${gateway.name}=http://127.0.0.1:8788/mcp-proxy/${mcpProxySecret}/${encodeURIComponent(gateway.name)}`, + ); + } + // The lifetime pipe is best-effort: without mkfifo(1) the daemon still + // starts, it just loses the parent-crash cleanup (SIGINT/SIGTERM reaping + // below still covers the clean paths). + let lifetime = null; + try { + lifetime = await createLifetimePipe(); + // The FIFO's read end lands at fd 3 in the child (stdio index 3 below). + args.push("--lifetime-pipe-fd", "3"); + } catch (error) { + process.stderr.write( + `[supervisor] lifetime pipe unavailable (${error.message || error}); spawning without parent-crash protection\n`, + ); + } + const proc = spawn(binary, args, { + cwd: mecatlDir, + env, + stdio: [ + "ignore", + "ignore", + "pipe", + ...(lifetime ? [lifetime.readEnd.fd] : []), + ], + }); + child = proc; + if (lifetime) { + // The child owns its duplicate of the read end now; the controller keeps + // ONLY the write end, open and never written to, until this child exits. + void lifetime.readEnd.close().catch(() => undefined); + const writeEnd = lifetime.writeEnd; + const releaseWriteEnd = () => void writeEnd.close().catch(() => undefined); + proc.once("exit", releaseWriteEnd); + proc.once("error", releaseWriteEnd); + } + proc.stderr.on("data", (chunk) => { + const text = chunk.toString(); + startupLog = (startupLog + text).slice(-24_000); + // The file + the GET /logs ring always receive the text (that is what + // makes the diagnostics operator-recoverable); the controller-side + // `quiet` switch (POST /diagnostics-options) mutes ONLY the mirror onto + // the controller's own terminal — mecatui's --quiet, minus the file. + recordDaemonLog(text); + if (!diagnosticsOptions.quiet) process.stderr.write(`[mecatl] ${text}`); + }); + proc.once("exit", () => { + if (child === proc) child = null; + if (!expectedExits.has(proc)) scheduleMecatlRestart(); + }); + provider = providerLabel(kind); + // The ready file replaces the old connect-poll + stability window: it is + // written atomically and only after composition and every listener are up, + // so its appearance IS readiness and its http_address arrives resolved. + let doc; + try { + doc = await waitForReadyDoc(proc); + } catch (error) { + // The admin port was probed, not kernel-assigned, so another process + // can take it between the probe and mecated's bind. One re-probe + + // re-spawn covers that race (the dead child's stderr is in startupLog); + // a second failure is a real startup error and surfaces as one. + if (adminAddr !== "" && !adminRetry && isAddressInUse(startupLog)) { + supervisorNote( + `[supervisor] admin listener ${adminAddr} was taken before mecated bound it; retrying on another port\n`, + ); + return startMecatl(kind, { adminRetry: true }); + } + throw startupFailure(kind, error.message || "mecatl did not become ready"); + } + mecatlBaseURL = `http://${doc.http_address}`; + readyInfo = { + apiMajor: Number(doc.api_major ?? 0), + features: Array.isArray(doc.features) + ? doc.features.filter((feature) => typeof feature === "string") + : [], + deployment: typeof doc.deployment === "string" ? doc.deployment : "", + }; + // MCP gateway construction happens during composition, BEFORE the ready + // file is written — so when the daemon came up degraded rather than dead, + // the handshake failure is already in the startup log. + if (gateway && /Unauthorized/i.test(startupLog)) { + throw new Error( + "MCP Gateway rejected the bearer token (Unauthorized). Paste a current gateway access token and try again.", + ); + } + if ( + gateway && + /MCP manager construction failed|no servers could be connected/i.test( + startupLog, + ) + ) { + throw new Error( + "MCP Gateway could not be initialized. Check that the URL is a Streamable HTTP endpoint and that its credential is valid.", + ); + } + restartFailures = 0; +} + +const server = http.createServer(async (request, response) => { + const requestURL = new URL(request.url, "http://127.0.0.1:8788"); + const mcpProxyPrefix = `/mcp-proxy/${mcpProxySecret}/`; + if ( + !requestIsAllowed(request, requestURL, { allowedOrigins, mcpProxyPrefix }) + ) { + jsonError(response, 403, "request origin is not allowed"); + return; + } + if (requestURL.pathname.startsWith("/mecatl/")) { + try { + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : await readBody(request); + const headers = {}; + for (const key of [ + "content-type", + "accept", + "mcp-session-id", + "mcp-protocol-version", + "last-event-id", + ]) { + if (request.headers[key]) headers[key] = request.headers[key]; + } + const path = + requestURL.pathname.slice("/mecatl".length) + requestURL.search; + const upstream = await fetchMecatl(path, { + method: request.method, + headers, + body, + redirect: "manual", + }); + response.statusCode = upstream.status; + for (const key of [ + "content-type", + "cache-control", + "mcp-session-id", + "www-authenticate", + ]) { + const value = upstream.headers.get(key); + if (value) response.setHeader(key, value); + } + if (upstream.body) + for await (const chunk of upstream.body) response.write(chunk); + response.end(); + } catch (error) { + jsonError( + response, + error.statusCode || 502, + error.message || "mecated proxy failed", + ); + } + return; + } + if (requestURL.pathname.startsWith(mcpProxyPrefix)) { + const proxyName = decodeURIComponent( + requestURL.pathname.slice(mcpProxyPrefix.length), + ); + if (!gateway || proxyName !== gateway.name) { + response.statusCode = 404; + response.end("gateway not configured"); + return; + } + if (request.method === "GET") { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + response.write(": loopback notification channel\n\n"); + const heartbeat = setInterval( + () => response.write(": keepalive\n\n"), + 15_000, + ); + request.once("close", () => clearInterval(heartbeat)); + return; + } + if (!["POST", "DELETE"].includes(request.method || "")) { + response.statusCode = 405; + response.end("method not allowed"); + return; + } + try { + const requestBody = + request.method === "POST" ? await readBody(request) : undefined; + await refreshGatewayAccessToken(false); + const headers = { Authorization: `Bearer ${gateway.token}` }; + for (const key of [ + "content-type", + "accept", + "mcp-session-id", + "mcp-protocol-version", + "last-event-id", + ]) { + if (request.headers[key]) headers[key] = request.headers[key]; + } + let upstream = await fetch(gateway.url, { + method: request.method, + headers, + body: requestBody, + }); + if (upstream.status === 401 && gateway.refreshToken) { + await upstream.arrayBuffer(); + await refreshGatewayAccessToken(true); + headers.Authorization = `Bearer ${gateway.token}`; + upstream = await fetch(gateway.url, { + method: request.method, + headers, + body: requestBody, + }); + } + process.stderr.write( + `[mcp-proxy] ${request.method} ${upstream.status} ${upstream.headers.get("content-type") || ""}\n`, + ); + response.statusCode = upstream.status; + for (const key of [ + "content-type", + "cache-control", + "mcp-session-id", + "www-authenticate", + ]) { + const value = upstream.headers.get(key); + if (value) response.setHeader(key, value); + } + if (upstream.body) { + for await (const chunk of upstream.body) response.write(chunk); + } + response.end(); + } catch (error) { + process.stderr.write( + `[mcp-proxy] ${request.method} failed: ${error.message || error}\n`, + ); + jsonError( + response, + error.statusCode || 502, + error.message || "gateway proxy failed", + ); + } + return; + } + response.setHeader("Content-Type", "application/json"); + response.setHeader("Cache-Control", "no-store"); + if (request.method === "GET" && requestURL.pathname === "/mcp/oauth/start") { + try { + const name = requestURL.searchParams.get("name") || ""; + const gatewayURL = new URL(requestURL.searchParams.get("url") || ""); + if (!/^[A-Za-z0-9_]+$/.test(name)) + throw new Error( + "Gateway name may contain only letters, numbers, and underscores", + ); + if (gatewayURL.protocol !== "https:") + throw new Error("OAuth gateways must use HTTPS"); + const discovery = await discoverOAuth(gatewayURL); + const registrationResponse = await fetch(discovery.registrationEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Mecatl Studio", + application_type: "native", + redirect_uris: [oauthRedirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + signal: AbortSignal.timeout(10_000), + }); + const registration = await registrationResponse.json().catch(() => ({})); + if (!registrationResponse.ok) + throw new Error( + registration.error_description || + registration.error || + "Gateway refused OAuth client registration", + ); + if (!registration.client_id) + throw new Error("Gateway registration did not return a client ID"); + const state = randomBytes(24).toString("base64url"); + const verifier = randomBytes(48).toString("base64url"); + const challenge = createHash("sha256") + .update(verifier) + .digest("base64url"); + const scope = registration.scope || discovery.scope; + oauthAttempts.set(state, { + name, + url: gatewayURL.toString(), + clientId: registration.client_id, + tokenEndpoint: discovery.tokenEndpoint, + scope, + resource: discovery.resource, + verifier, + expires: Date.now() + 10 * 60_000, + }); + const authorizationURL = new URL(discovery.authorizationEndpoint); + authorizationURL.searchParams.set("response_type", "code"); + authorizationURL.searchParams.set("client_id", registration.client_id); + authorizationURL.searchParams.set("redirect_uri", oauthRedirectUri); + authorizationURL.searchParams.set("scope", scope); + if (discovery.resource) + authorizationURL.searchParams.set("resource", discovery.resource); + authorizationURL.searchParams.set("state", state); + authorizationURL.searchParams.set("code_challenge", challenge); + authorizationURL.searchParams.set("code_challenge_method", "S256"); + if (requestURL.searchParams.get("redirect") === "1") { + response.writeHead(302, { Location: authorizationURL.toString() }); + response.end(); + } else { + response.end( + JSON.stringify({ authorizationUrl: authorizationURL.toString() }), + ); + } + } catch (error) { + response.statusCode = 400; + response.end( + JSON.stringify({ + error: error.message || "Could not start gateway sign-in", + }), + ); + } + return; + } + if (request.method === "GET" && requestURL.pathname === "/oauth/callback") { + response.setHeader("Content-Type", "text/html; charset=utf-8"); + const state = requestURL.searchParams.get("state") || ""; + const attempt = oauthAttempts.get(state); + oauthAttempts.delete(state); + try { + if (requestURL.searchParams.get("error")) + throw new Error( + requestURL.searchParams.get("error_description") || + requestURL.searchParams.get("error"), + ); + if (!attempt || attempt.expires < Date.now()) + throw new Error( + "The gateway sign-in attempt expired. Start it again from Mecatl Studio.", + ); + const code = requestURL.searchParams.get("code"); + if (!code) + throw new Error("Gateway sign-in did not return an authorization code"); + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: attempt.clientId, + redirect_uri: oauthRedirectUri, + code_verifier: attempt.verifier, + }); + if (attempt.resource) tokenBody.set("resource", attempt.resource); + const tokenResponse = await fetch(attempt.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenBody, + signal: AbortSignal.timeout(15_000), + }); + const tokenResult = await tokenResponse.json().catch(() => ({})); + if (!tokenResponse.ok || !tokenResult.access_token) + throw new Error( + tokenResult.error_description || + tokenResult.error || + "Gateway token exchange failed", + ); + await queueRestart(async () => { + const previousGateway = gateway; + gateway = { + name: attempt.name, + url: attempt.url, + token: tokenResult.access_token, + refreshToken: tokenResult.refresh_token || "", + clientId: attempt.clientId, + tokenEndpoint: attempt.tokenEndpoint, + scope: attempt.scope, + resource: attempt.resource, + expiresAt: + Date.now() + + Math.max(60, Number(tokenResult.expires_in) || 300) * 1000, + }; + try { + await startMecatl(preferredKind()); + } catch (error) { + gateway = previousGateway; + await startMecatl(preferredKind()); + throw error; + } + }); + response.end( + `Mecatl Gateway Connected

Gateway connected

You can close this window.

`, + ); + } catch (error) { + const message = String(error.message || "Gateway sign-in failed").replace( + /[<>&"']/g, + "", + ); + process.stderr.write(`[oauth] callback failed: ${message}\n`); + response.statusCode = 400; + response.end( + `Mecatl Gateway Error

Could not connect

${message}

`, + ); + } + return; + } + if ( + request.method === "POST" && + requestURL.pathname === "/mcp/oauth/refresh" + ) { + try { + if (!gateway?.refreshToken) + throw new Error("Gateway does not have a refresh token; sign in again"); + await refreshGatewayAccessToken(true); + response.end(JSON.stringify({ ok: true })); + } catch (error) { + response.statusCode = 400; + response.end( + JSON.stringify({ + error: error.message || "Gateway token refresh failed", + }), + ); + } + return; + } + // Restart mecated with the CURRENT provider, gateway and router config. The + // daemon resolves skills and agent definitions once at startup (ListSkills is + // a pure snapshot read), so a newly authored SKILL.md only reaches the model + // after a restart. Provider credentials remain owned by mecated's auth file. + if (request.method === "POST" && requestURL.pathname === "/restart") { + try { + await queueRestart(async () => { + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, provider })); + } catch (error) { + response.statusCode = 400; + response.end( + JSON.stringify({ error: error.message || "Could not restart mecated" }), + ); + } + return; + } + if (request.method === "GET" && requestURL.pathname === "/status") { + const configuredProviders = await listSelectableProviderNames(); + response.end( + JSON.stringify({ + mode: "managed", + provider, + isMock: provider === "offline mock", + running: Boolean(child), + startupError, + authFile, + // Where a custom `providers:` block lands (ADR 0238): the imported + // operator-settings.yaml when one is active (a CLI-tier file wins + // that section whole-block), otherwise the user-global settings. + settingsFile: operatorSettingsActive + ? operatorSettingsFile + : userSettingsFile, + // The child's ready-file compatibility descriptor (never a + // credential): api_major, feature identifiers, and the operator's + // deployment label, verbatim from mecated-ready/1. + apiMajor: readyInfo?.apiMajor ?? null, + features: readyInfo?.features ?? [], + deployment: readyInfo?.deployment ?? "", + // Names only — never values. What MECATL_STUDIO_PROVIDER / the + // /providers/active switch may select among (auth.yaml blocks plus + // settings-defined custom providers), and which one is active right + // now, if any. + configuredProviders, + selectedProvider: activeProviderOverride, + // The LIVE workspace root the daemon was spawned against (a display + // label in the browser, never a placement input — Studio rule 2) + // and the default it falls back to; POST /workspace changes it. + workspace, + defaultWorkspace, + gateway: gateway ? { name: gateway.name, url: gateway.url } : null, + toolhiveGateway: { + available: toolhiveReady, + baseURL: toolhiveGatewayURL, + active: provider === "ToolHive LLM gateway", + // Whether the thv CLI was found on PATH at boot (presence only), + // so the UI can offer POST /toolhive/start instead of a hint. + thvOnPath, + }, + modelRouter: modelRouterConfig + ? { + enabled: modelRouterConfig.enabled, + categories: modelRouterConfig.categories.length, + } + : null, + operatorSettings: operatorSettingsActive, + // The saved permissions the daemon was spawned with (flags, never a + // settings.yaml key) plus the volatile trust-once grant. The + // EFFECTIVE posture is the daemon's own capabilities.posture. + permissions: { + posture: permissionsConfig.posture, + trustProject: permissionsConfig.trustProject, + noShell: permissionsConfig.noShell, + trustOnce, + }, + // The controller's OWN project-trust registry as resolved for the + // CURRENT spawn (src/lib/controller-trust.mjs): whether the + // workspace carries anything a grant would admit, the decision the + // spawn actually got, who granted it, and the live identity anchor + // (an opaque hash the banner keys its "Not now" on). Computed at + // spawn time — never here — and it cannot see the daemon's own + // registry (its remembered / declared grants), which the UI states. + trust: { + hasAuthority: trustAuthority, + ...trustDecision(permissionsConfig, { trustOnce, trustDrifted }), + anchor: liveTrustAnchor, + }, + // The skills/memory directories the CURRENT child was spawned with + // (or would be — `enabled: false` means the spawn omitted the + // flag), scoped by which allowed root holds them. + skills: { + dir: skillsDir, + scope: optionDirScope(skillsDir, { + workspace, + defaultWorkspace, + configDir: mecatlConfigDir, + }), + enabled: daemonOptions.skills.enabled, + }, + memory: { + dir: memoryDir, + scope: optionDirScope(memoryDir, { + workspace, + defaultWorkspace, + configDir: mecatlConfigDir, + }), + enabled: daemonOptions.projectMemory.enabled, + }, + // The saved daemon options the child was spawned with (spawn flags: + // the two memory stores, the Skill tool, slash commands, MCP + // discovery) — switches only, no paths beyond the two above. + daemonOptions: daemonOptionsStatus(), + // The session store the daemon was spawned with: durable dir + // (resolved) or in-memory, plus the default the form falls back to. + // Paths only — the store's CONTENTS never cross here. + storage: currentStorageStatus(), + // The retention flags the daemon was spawned with (or that mecated's + // defaults apply) and who manages them. The EFFECTIVE policy is the + // daemon's own GET /v1/storage/health. + retention: retentionStatus(), + // The saved daemon defaults the child was spawned with (spawn + // flags: default/subagent model per provider, effort, context + // window, prompt caching, base URLs, ToolHive, aliases/slots, the + // credentials-file PATH — never a key) — one poll for the UI. + daemonDefaults, + // The saved diagnostics options the child was spawned with (log + // level, admin listener, product-metrics opt-out, controller-side + // quiet) plus the effective product-metrics verdict — read-only, + // nothing secret-shaped. The posture stays under `permissions`. + diagnosticsOptions: diagnosticsStatus(), + // The LIVE runtime admin surface: whether this child was spawned + // with the loopback admin listener, the origin the controller chose + // for it and the perf MCP mount — the same payload as GET /perf. + perf: perfStatus(diagnosticsOptions, adminAddr), + }), + ); + return; + } + // Provider inventory: names + key-present booleans from auth.yaml, plus the + // settings-defined custom providers (ADR 0238) — a keyless custom provider + // has no auth.yaml block, so the auth scan alone would hide it. Never + // values. Like the skill routes (and unlike /status) this is NOT in the + // header-free read-only allowlist — it needs the server-set studio header. + if (request.method === "GET" && requestURL.pathname === "/providers") { + const authRows = listAuthFileProviders(await readAuthFileText()); + const custom = await listCustomSettingsProviders(); + const customNames = new Set(custom.map((definition) => definition.name)); + // Every row is shaped by describeProviderRow (src/lib/provider-auth.mjs, + // vitest-covered): class, auth method + state (env shadowing included), + // default model, next step — from BOOLEANS and non-secret definition + // fields only. The running daemon's inventory decides the "restart to + // load the key" hint; the spawn kind decides `active`. + const inventory = await daemonProviderIds(); + const inInventory = (name) => (inventory ? inventory.has(name) : null); + const savedDefaultModel = (kind) => + daemonDefaults.models[kind]?.defaultModel ?? ""; + const currentKind = preferredKind(); + const providers = authRows + .filter((row) => !customNames.has(row.name)) + .map((row) => ({ + ...describeProviderRow({ + kind: row.name, + hasBlock: true, + keyPresent: row.keyPresent, + envShadowed: providerEnvShadowed(row.name), + active: currentKind === row.name, + inDaemonInventory: inInventory(row.name), + defaultModel: savedDefaultModel(row.name), + }), + testable: Object.hasOwn(providerKeyProbes, row.name), + })); + for (const definition of custom) { + const keyed = definition.authMethod === "api_key"; + const authRow = authRows.find((row) => row.name === definition.name); + providers.push({ + ...describeProviderRow({ + kind: definition.name, + definition, + hasBlock: Boolean(authRow), + keyPresent: Boolean(authRow?.keyPresent), + active: currentKind === definition.name, + inDaemonInventory: inInventory(definition.name), + defaultModel: savedDefaultModel(definition.name), + }), + testable: + keyed && + customProviderProbeURL(definition.apiFlavor, definition.baseURL) !== + "", + }); + } + // The UNCONFIGURED built-in kinds too (`configured: false`, or true when + // their env var alone configures them), so the UI can describe any kind + // the way `providers status PROVIDER` does and offer the guided add. + const listed = new Set(providers.map((row) => row.name)); + for (const entry of KNOWN_AUTH_PROVIDERS) { + if (listed.has(entry.name)) continue; + providers.push({ + ...describeProviderRow({ + kind: entry.name, + envShadowed: providerEnvShadowed(entry.name), + active: currentKind === entry.name, + inDaemonInventory: inInventory(entry.name), + defaultModel: savedDefaultModel(entry.name), + }), + testable: false, + }); + } + // The ToolHive LLM gateway as an EXTERNAL-class row: reachability from + // the loopback probe, whether thv is installed (so Studio can start the + // proxy), and the probe URL for display. + providers.push({ + ...describeToolhiveRow({ + reachable: toolhiveReady, + active: provider === "ToolHive LLM gateway", + baseURL: toolhiveGatewayURL, + thvOnPath, + enabled: daemonDefaults.toolhive.enabled, + }), + testable: false, + }); + response.end(JSON.stringify({ providers })); + return; + } + // The provider kinds the daemon understands, with the guided-add snippet + // (a placeholder — this route never sees a real credential). + if (request.method === "GET" && requestURL.pathname === "/providers/known") { + response.end(JSON.stringify({ known: KNOWN_AUTH_PROVIDERS })); + return; + } + // Starts the ToolHive LLM gateway proxy for the operator — the `providers + // setup` delegation to `thv llm`. A DETACHED spawn with ignored stdio (the + // proxy outlives the controller) of a FIXED argv, then a bounded readiness + // poll that updates toolhiveReady. thv's INTERACTIVE browser login is + // deliberately not driven from here: with no cached token the proxy blocks + // on it, the poll simply times out, and the answer tells the operator to + // run `thv llm login` in a terminal first. Nothing here restarts mecated — + // "Set as active" on the row does that. Header-gated like every write. + if (request.method === "POST" && requestURL.pathname === "/toolhive/start") { + if (!thvOnPath) { + jsonError( + response, + 409, + "ToolHive's thv CLI is not on the controller's PATH — install ToolHive, then run `thv llm proxy start` yourself.", + ); + return; + } + if (!toolhiveReady) { + try { + const proxy = spawn("thv", ["llm", "proxy", "start"], { + detached: true, + stdio: "ignore", + }); + proxy.on("error", (error) => { + process.stderr.write( + `[toolhive] thv llm proxy start failed: ${error.message || error}\n`, + ); + }); + proxy.unref(); + } catch (error) { + jsonError( + response, + 500, + `Could not start thv: ${error.message || error}`, + ); + return; + } + // Up to ~8 s in total: each probe is itself bounded (2.5 s), so a proxy + // parked on its login prompt cannot hold this request for long. + const deadline = Date.now() + 8000; + while (!toolhiveReady && Date.now() < deadline) { + await delay(500); + toolhiveReady = await detectToolhiveGateway(); + } + } + response.end( + JSON.stringify({ + ok: true, + available: toolhiveReady, + hint: toolhiveReady + ? "" + : "The gateway did not answer within 8 seconds. If ToolHive has no cached login, run `thv llm login` in a terminal first, then re-check.", + }), + ); + return; + } + // Live provider switch: POST /providers/active { kind }. Unlike + // MECATL_STUDIO_PROVIDER (fixed for the process's lifetime), this + // reassigns activeProviderOverride and restarts mecated on the spot — the + // Studio settings UI's "switch to mock" / "switch to " control. + // Checked BEFORE the /providers/{name} regex below, which would otherwise + // match "active" as a provider name and 404 it first. + if ( + request.method === "POST" && + requestURL.pathname === "/providers/active" + ) { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const input = JSON.parse( + (await readBody(request, 4096)).toString("utf8"), + ); + const kind = + typeof input?.kind === "string" ? input.kind.trim().toLowerCase() : ""; + const selectableNames = await listSelectableProviderNames(); + if (!kind || !isSelectableProviderKind(kind, selectableNames)) { + throw Object.assign( + new Error( + kind === "toolhive" + ? daemonDefaults.toolhive.enabled + ? "The ToolHive LLM gateway is not reachable right now" + : "ToolHive gateway detection is switched off in Daemon defaults — enable it there first" + : `"${kind || "(empty)"}" is not mock, toolhive, a provider configured in ${authFile}, or a custom provider defined in the operator settings`, + ), + { statusCode: 400 }, + ); + } + await queueRestart(async () => { + const previous = activeProviderOverride; + activeProviderOverride = kind; + try { + await startMecatl(preferredKind()); + } catch (error) { + activeProviderOverride = previous; + await startMecatl(preferredKind()); + throw error; + } + // The choice is DURABLE from here: it is re-read at the next + // controller boot (MECATL_STUDIO_PROVIDER, when set, still wins). + // Persisting only after a successful start keeps a kind that could + // not start out of the file. A failed write is logged, not fatal — + // the daemon IS running on the new provider. + try { + applyDaemonDefaults({ ...daemonDefaults, activeProvider: kind }); + await persistDaemonDefaults(daemonDefaults); + } catch (error) { + process.stderr.write( + `[daemon-defaults] active provider not persisted: ${error.message || error}\n`, + ); + } + }); + response.end( + JSON.stringify({ + ok: true, + provider, + selectedProvider: activeProviderOverride, + }), + ); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not switch provider", + ); + } + return; + } + // Custom provider DEFINITION write (`providers add`, ADR 0238): POST + // /providers/custom { id, apiFlavor, baseURL, defaultModel, authMethod }. + // The definition is NON-secret by construction — an id, a wire flavor, an + // HTTPS base URL, a model id and an auth METHOD — so the controller may + // write it into the user-global settings.yaml (the file mecated reads at + // the operator tier) the way it already writes the router/runtime files: + // the same line-range discipline as removal (provider-auth.mjs), temp-file + // + rename, the file's existing mode preserved (0600 when created). The + // api_key itself still travels by hand into auth.yaml (Studio rule 3): + // there is no key field here and none is accepted. Refused (409) while an + // imported operator-settings.yaml is active — that CLI-tier file wins the + // whole providers: section when it has one, and Studio never edits it — + // and when the id is already configured anywhere. A keyless + // (auth.method none) provider is complete on write, so the daemon + // restarts at once; an api_key one waits for the key (the UI's Restart). + // Checked BEFORE the /providers/{name} regex below, which would otherwise + // read "custom" as a provider name. + if ( + request.method === "POST" && + requestURL.pathname === "/providers/custom" + ) { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const input = JSON.parse( + (await readBody(request, 8192)).toString("utf8"), + ); + const field = (key) => + typeof input?.[key] === "string" ? input[key].trim() : ""; + const definition = { + id: field("id"), + apiFlavor: field("apiFlavor"), + baseURL: field("baseURL"), + defaultModel: field("defaultModel"), + authMethod: field("authMethod"), + }; + const invalid = (message) => + Object.assign(new Error(message), { statusCode: 400 }); + if (!validCustomProviderId(definition.id)) + throw invalid( + "not a valid custom provider id (lower-case letters, digits and hyphens, starting with a letter; built-in names are reserved)", + ); + if (!CUSTOM_PROVIDER_API_FLAVORS.includes(definition.apiFlavor)) + throw invalid( + `apiFlavor must be one of ${CUSTOM_PROVIDER_API_FLAVORS.join(", ")}`, + ); + if (!validCustomProviderBaseURL(definition.baseURL)) + throw invalid( + "baseURL must be an HTTPS URL without credentials, query, or fragment", + ); + if (definition.defaultModel === "") + throw invalid("defaultModel is required"); + if ( + definition.authMethod !== "api_key" && + definition.authMethod !== "none" + ) + throw invalid('authMethod must be "api_key" or "none"'); + if (operatorSettingsActive) + throw Object.assign( + new Error( + "Refused while an imported operator settings file is active: Studio does not write the settings file then. Add the providers: entry to that file by hand, then restart the daemon.", + ), + { statusCode: 409 }, + ); + const configured = new Set([ + ...(await listConfiguredProviderNames()), + ...(await listCustomSettingsProviders()).map((entry) => entry.name), + ]); + if (configured.has(definition.id)) + throw Object.assign( + new Error( + `"${definition.id}" is already configured (an ${authFile} block or a providers: definition exists) — remove it first.`, + ), + { statusCode: 409 }, + ); + let currentText = ""; + let mode = 0o600; + try { + currentText = await readFile(userSettingsFile, "utf8"); + mode = (await stat(userSettingsFile)).mode & 0o777; + } catch (error) { + if (error?.code !== "ENOENT") + throw Object.assign( + new Error( + `Could not read ${userSettingsFile}: ${error.message || error}`, + ), + { statusCode: 500 }, + ); + } + const { text, written, reason } = upsertSettingsProvider( + currentText, + definition, + ); + if (!written) throw Object.assign(new Error(reason), { statusCode: 409 }); + await mkdir(dirname(userSettingsFile), { recursive: true }); + const temp = `${userSettingsFile}.tmp`; + await writeFile(temp, text, { mode }); + await rename(temp, userSettingsFile); + // The definition STANDS from here: a failed restart is reported on + // the success body (restarted:false + the cause), never as a failure + // of the write the operator asked for. + let restarted = false; + let restartError = ""; + if (definition.authMethod === "none") { + try { + await queueRestart(() => startMecatl(preferredKind())); + restarted = true; + } catch (error) { + restartError = error.message || String(error); + } + } + response.end( + JSON.stringify({ + ok: true, + restarted, + restartError, + settingsFile: userSettingsFile, + }), + ); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not save the provider definition", + ); + } + return; + } + // Key test + removal: POST /providers/{name}/test, DELETE /providers/{name}. + const providerRoute = requestURL.pathname.match( + /^\/providers\/([^/]+?)(?:\/(test))?$/, + ); + if (providerRoute) { + const [, rawName, action] = providerRoute; + let name = rawName; + try { + name = decodeURIComponent(rawName); + } catch { + // Malformed escape: the grammar below rejects percent-shaped names. + } + if (!validProviderName(name)) { + jsonError(response, 400, "not a valid provider name"); + return; + } + if (request.method === "POST" && action === "test") { + // ONE cheap authenticated call with the STORED key, made entirely + // server-side. The key never appears in the response, the logs, or an + // error message; the probe is bounded (10s) and never follows a + // redirect with the credential attached. A settings-defined custom + // provider (ADR 0238) is probed against ITS OWN base URL with the + // header shape its api_flavor dictates. + const definition = (await listCustomSettingsProviders()).find( + (candidate) => candidate.name === name, + ); + let buildProbe = null; + if (definition) { + if (definition.authMethod !== "api_key") { + jsonError( + response, + 400, + `"${name}" uses auth.method none — there is no key to test.`, + ); + return; + } + if ( + customProviderProbeURL(definition.apiFlavor, definition.baseURL) === + "" + ) { + jsonError( + response, + 400, + `Key testing is not supported for "${name}" — its api_flavor or base_url cannot be probed; mecated will report an auth problem on first use instead.`, + ); + return; + } + buildProbe = (key) => customProviderKeyProbe(definition, key); + } else if (Object.hasOwn(providerKeyProbes, name)) { + buildProbe = providerKeyProbes[name]; + } else { + jsonError( + response, + 400, + `Key testing is not supported for "${name}" — mecated will report an auth problem on first use instead.`, + ); + return; + } + const key = readProviderCredential(await readAuthFileText(), name); + if (!key) { + jsonError( + response, + 400, + `No api_key found for providers.${name} in ${authFile}`, + ); + return; + } + response.end(JSON.stringify(await probeProviderKey(buildProbe(key)))); + return; + } + if (request.method === "DELETE" && !action) { + // Two scopes, the TUI's two verbs. `?scope=credential` (`providers + // logout`) cuts ONLY the api_key line from the provider's auth.yaml + // block — the block and, for a custom provider, its settings.yaml + // definition stay, so it lists as "configured, no key". `?scope=all` + // (`providers remove`, the default) cuts the whole auth.yaml block + // AND a custom provider's definition from the user-global + // settings.yaml (a built-in has no definition, so only the block + // goes). Every cut is a conservative line-range edit + // (provider-auth.mjs) written temp-file+rename — owner-only for + // auth.yaml, the file's existing mode for settings.yaml — then a + // daemon restart so the change is real. A definition living in the + // ACTIVE imported operator-settings.yaml is refused (409) before + // anything is written: Studio never edits that file. Removing the + // LAST provider is allowed: mecated runs on the offline mock without + // providers (the state the user already sees on first run) — the + // UI's confirm warns, the controller doesn't refuse. If the restart + // then fails (e.g. MECATL_STUDIO_PROVIDER still names the removed + // provider), the removal STANDS — the operator asked for it to be + // gone — and the startup error surfaces both in this response and on + // /status.startupError. No value is ever logged or echoed. + const scope = requestURL.searchParams.get("scope") || "all"; + if (scope !== "all" && scope !== "credential") { + jsonError(response, 400, 'scope must be "credential" or "all"'); + return; + } + try { + await queueRestart(async () => { + const current = await readAuthFileText(); + let authText = current; + let authChanged = false; + if (scope === "credential") { + const cut = removeAuthFileProviderKey(current, name); + if (!cut.removed) + throw Object.assign( + new Error( + `No api_key found for providers.${name} in ${authFile}`, + ), + { statusCode: 404 }, + ); + authText = cut.text; + authChanged = true; + } else { + if (operatorSettingsActive) { + let operatorText = ""; + try { + operatorText = await readFile(operatorSettingsFile, "utf8"); + } catch { + // Unreadable = it defines nothing; the user-global file is + // what mecated sees. + } + const owned = listSettingsProviders(operatorText); + if (owned?.some((entry) => entry.name === name)) + throw Object.assign( + new Error( + `"${name}" is defined in the imported operator settings file (providers: section), which Studio never edits — refused while that file is active. Remove the entry there by hand, then restart the daemon.`, + ), + { statusCode: 409 }, + ); + } + const cut = removeAuthFileProvider(current, name); + authText = cut.text; + authChanged = cut.removed; + let settingsText = ""; + let settingsMode = 0o600; + let hasSettings = false; + try { + settingsText = await readFile(userSettingsFile, "utf8"); + settingsMode = (await stat(userSettingsFile)).mode & 0o777; + hasSettings = true; + } catch (error) { + if (error?.code !== "ENOENT") + throw Object.assign( + new Error( + `Could not read ${userSettingsFile}: ${error.message || error}`, + ), + { statusCode: 500 }, + ); + } + const definition = hasSettings + ? removeSettingsProvider(settingsText, name) + : { text: settingsText, removed: false }; + if (!authChanged && !definition.removed) + throw Object.assign( + new Error( + `No provider named "${name}" in ${authFile} or ${userSettingsFile}`, + ), + { statusCode: 404 }, + ); + if (definition.removed) { + const temp = `${userSettingsFile}.tmp`; + await writeFile(temp, definition.text, { mode: settingsMode }); + await rename(temp, userSettingsFile); + } + } + if (authChanged) { + const temp = `${authFile}.tmp`; + await writeFile(temp, authText, { mode: 0o600 }); + await rename(temp, authFile); + } + // A DURABLE active-provider choice naming the removed provider + // would wedge every later boot on a fail-fast --default-provider + // (a keyed provider without its key is not startable either), so + // it is cleared — and, when the whole provider goes, so is its + // saved model pair (a credential-only cut keeps the pair with the + // definition). The this-process override falls back too — unless + // MECATL_STUDIO_PROVIDER pins it, which the UI's confirm warns + // about and which the operator must change by hand. + const dropModels = + scope === "all" && Object.hasOwn(daemonDefaults.models, name); + if (daemonDefaults.activeProvider === name || dropModels) { + const { [name]: _dropped, ...models } = daemonDefaults.models; + applyDaemonDefaults({ + ...daemonDefaults, + models: dropModels ? models : daemonDefaults.models, + activeProvider: + daemonDefaults.activeProvider === name + ? null + : daemonDefaults.activeProvider, + }); + await persistDaemonDefaults(daemonDefaults); + } + if (activeProviderOverride === name && configuredProvider !== name) + activeProviderOverride = null; + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, restarted: true, scope })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Provider removal failed", + ); + } + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + return; + } + // The disabled-skill inventory. Like every controller route this sits + // behind requestIsAllowed (loopback Host + allowlisted Origin + the + // server-set studio header) — the Next server proxy is the only caller. + if (request.method === "GET" && requestURL.pathname === "/skills/disabled") { + response.end(JSON.stringify({ disabled: await listDisabledSkills() })); + return; + } + // Skill creation: POST /skills with { name, body } → // + // SKILL.md. A brand-new skill is invisible until the daemon rebuilds its + // startup snapshot, so creation always restarts mecated — serialized through + // queueRestart like every sibling mutation (existence probes included, so a + // queued restart cannot race them). + if (request.method === "POST" && requestURL.pathname === "/skills") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw skillClientError("Content-Type must be application/json", 415); + const input = JSON.parse( + (await readBody(request, maxSkillCreateBodyBytes)).toString("utf8"), + ); + if (typeof input?.name !== "string") + throw skillClientError("Provide the skill name as { name }"); + const name = input.name; + const paths = skillPaths(name); + // Two accepted shapes: { name, body } writes a lone SKILL.md; { name, + // files: [{ path, contentBase64 }] } writes a whole folder skill (a + // zip/folder upload). Either way a SKILL.md must land at the root. + let files; + if (Array.isArray(input?.files)) { + if ( + input.files.length === 0 || + input.files.length > maxSkillUploadFiles + ) + throw skillClientError( + `Provide between 1 and ${maxSkillUploadFiles} files`, + ); + let total = 0; + const seen = new Set(); + files = input.files.map((entry) => { + if ( + !validSkillUploadPath(entry?.path) || + typeof entry?.contentBase64 !== "string" + ) + throw skillClientError( + "Each file needs a safe relative { path } and { contentBase64 }", + ); + // The write below is on the default (case-insensitive) macOS + // filesystem — case-colliding paths would silently overwrite. + const key = entry.path.toLowerCase(); + if (seen.has(key)) + throw skillClientError( + `The upload holds duplicate paths: ${entry.path}`, + ); + seen.add(key); + const content = Buffer.from(entry.contentBase64, "base64"); + if (content.length > maxSkillUploadFileBytes) + throw skillClientError( + `"${entry.path}" exceeds the ${maxSkillUploadFileBytes}-byte per-file limit`, + 413, + ); + total += content.length; + return { path: entry.path, content }; + }); + if (total > maxSkillUploadTotalBytes) + throw skillClientError( + `The upload exceeds the ${maxSkillUploadTotalBytes}-byte total limit`, + 413, + ); + const skillMd = files.find((file) => file.path === "SKILL.md"); + if (!skillMd) + throw skillClientError( + "The upload needs a SKILL.md at the folder root", + ); + if (skillMd.content.length > maxSkillBodyBytes) + throw skillClientError( + `SKILL.md is limited to ${maxSkillBodyBytes} bytes`, + 413, + ); + } else { + if (typeof input?.body !== "string" || input.body.length === 0) + throw skillClientError("Provide the SKILL.md content as { body }"); + if (Buffer.byteLength(input.body, "utf8") > maxSkillBodyBytes) + throw skillClientError( + `SKILL.md is limited to ${maxSkillBodyBytes} bytes`, + 413, + ); + files = [{ path: "SKILL.md", content: Buffer.from(input.body) }]; + } + let restarted = false; + await queueRestart(async () => { + // A name taken on EITHER side is a collision: a same-named disabled + // skill would silently resurrect over this content when enabled. + if ( + (await isDirectory(paths.enabled)) || + (await isDirectory(paths.disabled)) + ) + throw skillClientError( + `A skill named "${name}" already exists under ${skillsDir}`, + 409, + ); + try { + for (const file of files) { + const target = resolve(paths.enabled, file.path); + // Defense-in-depth behind validSkillUploadPath. + if (!target.startsWith(paths.enabled + sep)) + throw skillClientError( + `Path escapes the skill folder: ${file.path}`, + ); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.content); + } + } catch (error) { + // The collision check above proved the dir was ours to create, so + // a half-written skill is safe to sweep away whole. + await rm(paths.enabled, { recursive: true, force: true }); + throw error; + } + restarted = true; + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, restarted })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Skill create failed", + ); + } + return; + } + // Skill CRUD: GET/PUT /skills/{name}/body, POST /skills/{name}/{enable| + // disable}, DELETE /skills/{name}. Mutations serialize through queueRestart + // (checks included, so a queued restart cannot race the side probes) and + // restart mecated whenever the change touches what its startup snapshot saw. + const skillRoute = requestURL.pathname.match( + /^\/skills\/([^/]+?)(?:\/(body|enable|disable|files|file))?$/, + ); + if (skillRoute) { + const [, rawName, action] = skillRoute; + let name = rawName; + try { + try { + name = decodeURIComponent(rawName); + } catch { + // Malformed escape: the raw segment is the only candidate, and the + // name grammar below rejects anything percent-shaped anyway. + } + const paths = skillPaths(name); + if (request.method === "GET" && action === "body") { + for (const side of [paths.enabled, paths.disabled]) { + let body; + try { + body = await readFile(resolve(side, "SKILL.md"), "utf8"); + } catch { + continue; // try the other side + } + response.end(JSON.stringify({ body })); + return; + } + throw skillClientError(`No skill named "${name}" has a SKILL.md`, 404); + } + // Read-only folder views: a skill can be a whole folder of assets + // (scripts/, references/, …), not just a SKILL.md. Listing and preview + // work on whichever side (enabled/disabled) holds the skill. + if (request.method === "GET" && action === "files") { + for (const side of [paths.enabled, paths.disabled]) { + if (!(await isDirectory(side))) continue; + response.end(JSON.stringify({ files: await listSkillFiles(side) })); + return; + } + throw skillClientError(`No skill named "${name}"`, 404); + } + if (request.method === "GET" && action === "file") { + const relPath = requestURL.searchParams.get("path") || ""; + const segments = relPath.split("/"); + if ( + !relPath || + relPath.includes("\\") || + segments.some( + (segment) => segment === "" || segment === "." || segment === "..", + ) + ) + throw skillClientError( + "Provide a relative file path inside the skill as ?path=", + ); + for (const side of [paths.enabled, paths.disabled]) { + if (!(await isDirectory(side))) continue; + const target = resolve(side, relPath); + if (!target.startsWith(side + sep)) + throw skillClientError("Path escapes the skill folder"); + let meta; + try { + meta = await lstat(target); + } catch { + throw skillClientError(`No file "${relPath}" in "${name}"`, 404); + } + if (!meta.isFile()) + throw skillClientError(`"${relPath}" is not a regular file`); + if (meta.size > maxSkillBodyBytes) + throw skillClientError( + `"${relPath}" is too large to preview (limit ${maxSkillBodyBytes} bytes)`, + 413, + ); + const bytes = await readFile(target); + if (bytes.includes(0)) + throw skillClientError( + `"${relPath}" is a binary file — no text preview`, + 415, + ); + response.end(JSON.stringify({ content: bytes.toString("utf8") })); + return; + } + throw skillClientError(`No skill named "${name}"`, 404); + } + if (request.method === "PUT" && action === "body") { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw skillClientError("Content-Type must be application/json", 415); + const input = JSON.parse( + (await readBody(request, maxSkillBodyBytes + 16_384)).toString( + "utf8", + ), + ); + if (typeof input?.body !== "string" || input.body.length === 0) + throw skillClientError("Provide the SKILL.md content as { body }"); + if (Buffer.byteLength(input.body, "utf8") > maxSkillBodyBytes) + throw skillClientError( + `SKILL.md is limited to ${maxSkillBodyBytes} bytes`, + 413, + ); + let restarted = false; + await queueRestart(async () => { + const onEnabled = await isDirectory(paths.enabled); + const onDisabled = await isDirectory(paths.disabled); + if (!onEnabled && !onDisabled) + throw skillClientError(`No skill named "${name}"`, 404); + const target = resolve( + onEnabled ? paths.enabled : paths.disabled, + "SKILL.md", + ); + const temp = `${target}.tmp`; + await writeFile(temp, input.body); + await rename(temp, target); + // A disabled skill is invisible to the daemon's snapshot, so + // editing it owes no restart. + if (onEnabled) { + restarted = true; + await startMecatl(preferredKind()); + } + }); + response.end(JSON.stringify({ ok: true, restarted })); + return; + } + if ( + request.method === "POST" && + (action === "enable" || action === "disable") + ) { + let restarted = false; + await queueRestart(async () => { + const onEnabled = await isDirectory(paths.enabled); + const onDisabled = await isDirectory(paths.disabled); + if (!onEnabled && !onDisabled) + throw skillClientError(`No skill named "${name}"`, 404); + if (onEnabled && onDisabled) + throw skillClientError( + `Both an enabled and a disabled "${name}" exist under ${skillsDir}; resolve the collision on disk first`, + 409, + ); + // Idempotent-ish: already on the requested side moves nothing and + // restarts nothing. + if (action === "disable" ? onDisabled : onEnabled) return; + if (action === "disable") { + await mkdir(disabledSkillsDir, { recursive: true }); + await rename(paths.enabled, paths.disabled); + } else { + await rename(paths.disabled, paths.enabled); + } + restarted = true; + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, restarted })); + return; + } + if (request.method === "DELETE" && !action) { + let restarted = false; + await queueRestart(async () => { + const onEnabled = await isDirectory(paths.enabled); + const onDisabled = await isDirectory(paths.disabled); + if (!onEnabled && !onDisabled) + throw skillClientError(`No skill named "${name}"`, 404); + // A delete means gone from BOTH sides — never a hidden disabled + // copy waiting to resurrect under the same name. + if (onDisabled) + await rm(paths.disabled, { recursive: true, force: true }); + if (onEnabled) { + await rm(paths.enabled, { recursive: true, force: true }); + restarted = true; + await startMecatl(preferredKind()); + } + }); + response.end(JSON.stringify({ ok: true, restarted })); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Skill update failed", + ); + } + return; + } + // Diagnostics options: log level, the loopback admin/metrics listener (+ + // perf MCP, goroutine alarm), product-metrics opt-out — spawn flags (see + // src/lib/controller-diagnostics-options.mjs) — plus the controller-side + // `quiet` switch. NOT in the header-free read-only allowlist (both verbs + // need the server-set studio header). The POST takes a PARTIAL document; + // a change to anything but `quiet` restarts the daemon, and a start + // mecated refuses on the new flags rolls the previous document back, + // restarts on it, and surfaces the refusal as the 400 body — the + // /permissions rollback idiom. The operator posture is NOT accepted here + // (mergeDiagnosticsOptions rejects the key): /permissions owns --posture. + if ( + request.method === "GET" && + requestURL.pathname === "/diagnostics-options" + ) { + response.end(JSON.stringify(diagnosticsStatus())); + return; + } + // The managed daemon's diagnostics log (see mecatedLogFile). GET /logs + // serves the bounded in-memory tail (`?lines=`, default 200, max 2000) + // with the file's path and size, the saved quiet/log-level knobs, liveness + // and the last startup error — so the Diagnostics page can explain a + // crash-at-start while the daemon itself is unreachable. GET + // /logs/download streams the current generation as plain text (404 while + // nothing has been written). The content is model-influenced, so the UI + // renders it as text only; both routes need the server-set studio header + // (neither is in the header-free read-only allowlist), and external mode + // answers 409 at the proxy — the deployment owns its daemon's logging. + if (request.method === "GET" && requestURL.pathname === "/logs") { + try { + const lines = parseTailLines(requestURL.searchParams.get("lines")); + response.end(JSON.stringify(await daemonLogStatus(lines))); + } catch (error) { + jsonError( + response, + error.statusCode || 500, + error.message || "Could not read the daemon log", + ); + } + return; + } + if (request.method === "GET" && requestURL.pathname === "/logs/download") { + try { + await stat(mecatedLogFile); + } catch { + jsonError(response, 404, "No daemon log has been written yet"); + return; + } + response.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": `attachment; filename="${LOG_FILE_NAME}"`, + "Cache-Control": "no-store", + }); + createReadStream(mecatedLogFile) + .on("error", () => response.destroy()) + .pipe(response); + return; + } + if ( + request.method === "POST" && + requestURL.pathname === "/diagnostics-options" + ) { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const patch = JSON.parse( + (await readBody(request, 16_384)).toString("utf8"), + ); + await queueRestart(async () => { + const previous = diagnosticsOptions; + const next = normalizeDiagnosticsOptions( + mergeDiagnosticsOptions(previous, patch), + ); + diagnosticsOptions = next; + await persistDiagnosticsOptions(next); + if (!diagnosticsRestartRequired(previous, next)) return; + try { + await startMecatl(preferredKind()); + } catch (error) { + diagnosticsOptions = previous; + await persistDiagnosticsOptions(previous); + await startMecatl(preferredKind()); + throw new Error( + `${error.message || error} (previous diagnostics options restored)`, + ); + } + }); + response.end(JSON.stringify({ ok: true, ...diagnosticsStatus() })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the diagnostics options", + ); + } + return; + } + // The runtime admin surface as the browser may see it (see + // src/lib/controller-perf.mjs). GET /perf reports whether THIS child was + // spawned with the loopback admin listener, the origin the controller + // chose for it, the paths mecated mounts there and the perf MCP / + // goroutine-alarm knobs. GET /perf/metrics and GET /perf/vars RELAY the + // two TEXT endpoints — the browser's CSP is connect-src 'self', so it can + // never fetch the loopback listener itself — verbatim (status + body, + // redirects never followed), 409 while the surface is off and 503 while + // no child is running. NEVER relayed: /debug/pprof and + // /debug/flightrecorder (binary, potentially large — the Performance card + // shows them as loopback links, which only resolve in a browser on the + // daemon's host). /metrics can embed prompt text and file paths, so all + // three routes need the server-set studio header (none is in the + // header-free read-only allowlist), and external mode answers 409 at the + // proxy — the deployment configures its own --metrics-addr / --perf-mcp. + if (request.method === "GET" && requestURL.pathname === "/perf") { + response.end(JSON.stringify(perfStatus(diagnosticsOptions, adminAddr))); + return; + } + const perfRelay = + request.method === "GET" ? perfProxyRoute(requestURL.pathname) : null; + if (perfRelay) { + if (adminAddr === "") { + jsonError(response, 409, "the runtime admin surface is off"); + return; + } + if (!child) { + jsonError(response, 503, "mecated is not running"); + return; + } + try { + const upstream = await fetch(`http://${adminAddr}${perfRelay.path}`, { + signal: AbortSignal.timeout(PERF_PROXY_TIMEOUT_MS), + redirect: "manual", + }); + const body = Buffer.from(await upstream.arrayBuffer()); + response.writeHead(upstream.status, { + "Content-Type": perfRelay.contentType, + "Cache-Control": "no-store", + }); + response.end(body); + } catch (error) { + jsonError( + response, + 502, + `mecated's admin listener did not answer: ${error.message || error}`, + ); + } + return; + } + // Permissions: the operator posture ladder, project trust and shell-less + // mode, as mecated spawn flags (see src/lib/controller-permissions.mjs). + // Like /providers (and unlike /status) the GET is NOT in the header-free + // read-only allowlist — it needs the server-set studio header. The write + // restarts the daemon; a start the new flags make mecated refuse (auto/yolo + // as root outside MECATL_SANDBOX) rolls the previous document back, + // restarts on it, and surfaces the refusal as the 400 body. + if (request.method === "GET" && requestURL.pathname === "/permissions") { + response.end( + JSON.stringify({ + config: permissionsConfig, + trustOnce, + operatorSettings: operatorSettingsActive, + }), + ); + return; + } + if (request.method === "POST" && requestURL.pathname === "/permissions") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const next = normalizePermissions( + JSON.parse((await readBody(request, 16_384)).toString("utf8")), + ); + // The trust ANCHOR is the controller's, never the client's: a FRESH + // grant (the switch turning on) stamps the live anchor; a save that + // merely keeps trustProject true (a posture or shell change) carries + // the stored anchor forward, so an unrelated save can never quietly + // re-accept drifted instructions — that takes the explicit + // POST /permissions/trust below. Turning trust off clears it. + next.trustAnchor = !next.trustProject + ? "" + : permissionsConfig.trustProject && permissionsConfig.trustAnchor + ? permissionsConfig.trustAnchor + : trustAnchor(workspace); + await restartOnPermissions(next); + response.end(JSON.stringify({ ok: true, config: permissionsConfig })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the permissions", + ); + } + return; + } + // The trust prompt's two grant answers (the workspace banner; mecatui's + // pre-TUI "trust / trust once / no"). Both are BODYLESS POSTs behind the + // studio header; each restarts the daemon with --trust-project. + // + // /permissions/trust is the REMEMBERED grant: it persists trustProject + // and stamps the LIVE anchor, so it is also how a drifted grant is + // re-accepted after the user has reviewed the changed instructions. It + // writes ONLY the controller's permissions.json — never the daemon's own + // registry file. + if ( + request.method === "POST" && + requestURL.pathname === "/permissions/trust" + ) { + try { + await restartOnPermissions({ + ...permissionsConfig, + trustProject: true, + trustAnchor: trustAnchor(workspace), + }); + response.end(JSON.stringify({ ok: true, config: permissionsConfig })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not trust the project", + ); + } + return; + } + // /permissions/trust-once grants for THIS controller process only: the + // in-memory flag rides every spawn until the controller exits and is + // never persisted. A start the grant somehow makes mecated refuse drops + // the grant again and restarts without it. + if ( + request.method === "POST" && + requestURL.pathname === "/permissions/trust-once" + ) { + try { + await queueRestart(async () => { + trustOnce = true; + try { + await startMecatl(preferredKind()); + } catch (error) { + trustOnce = false; + await startMecatl(preferredKind()); + throw error; + } + }); + response.end(JSON.stringify({ ok: true })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not trust the project for this session", + ); + } + return; + } + // Session store: POST /storage {persistence: "durable"|"memory", storeDir?} + // (see src/lib/storage-settings.mjs). Header-gated like every write. The + // daemon restarts on the new flag; a start the new document makes mecated + // refuse (or a store directory that cannot be created) rolls the previous + // document back — "no file" included — restarts on it, and surfaces the + // failure as the 400 body. /status.storage is the read half. + if (request.method === "POST" && requestURL.pathname === "/storage") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const next = normalizeStorageSettings( + JSON.parse((await readBody(request, 16_384)).toString("utf8")), + storageDefaults, + ); + await queueRestart(async () => { + const previous = storageSettings; + storageSettings = next; + await persistStorageSettings(next); + try { + await startMecatl(preferredKind()); + } catch (error) { + storageSettings = previous; + if (previous) await persistStorageSettings(previous); + else await rm(storageStateFile, { force: true }); + await startMecatl(preferredKind()); + throw new Error( + `${error.message || error} (previous session store restored)`, + ); + } + }); + response.end( + JSON.stringify({ ok: true, storage: currentStorageStatus() }), + ); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the session store", + ); + } + return; + } + // Workspace root: POST /workspace { path } — the TUI's `--workspace` + // deployment choice, made from Settings → Workspace. Header-gated like + // every write (a loopback-origin page must never point mecated at a + // directory of its choosing). The path is validated on the controller's + // own filesystem (absolute, exists, a directory, not Studio's state dir; + // src/lib/workspace-config.mjs) and the daemon restarts against it. A + // start the new root makes mecated refuse rolls the previous root back, + // restarts on it, and surfaces the failure as the 400 body. + // + // Trust does NOT travel with the root: a REMEMBERED grant was given for + // the previous root's instruction files (its anchor is a content hash, + // so two authority-less roots would otherwise read as the same grant), + // and the this-process "trust once" likewise — both are withdrawn on a + // change, and the new root's banner asks again if it carries authority. + // /status.workspace is the read half; the same-root save is a no-op. + if (request.method === "POST" && requestURL.pathname === "/workspace") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const body = JSON.parse( + (await readBody(request, 16_384)).toString("utf8"), + ); + const next = await validateWorkspacePath(body?.path, { + realpath, + stat, + defaultWorkspace, + forbidden: [studioStateDir], + }); + let changed = false; + if (next !== workspace) { + await queueRestart(async () => { + const previous = workspace; + const previousPermissions = permissionsConfig; + const previousTrustOnce = trustOnce; + applyWorkspace(next); + await persistWorkspace(next); + trustOnce = false; + if (permissionsConfig.trustProject) { + permissionsConfig = { + ...permissionsConfig, + trustProject: false, + trustAnchor: "", + }; + await persistPermissions(permissionsConfig); + } + try { + await startMecatl(preferredKind()); + changed = true; + } catch (error) { + applyWorkspace(previous); + await persistWorkspace(previous); + trustOnce = previousTrustOnce; + if (permissionsConfig !== previousPermissions) { + permissionsConfig = previousPermissions; + await persistPermissions(permissionsConfig); + } + await startMecatl(preferredKind()); + throw new Error( + `${error.message || error} (previous workspace root restored)`, + ); + } + }); + } + response.end( + JSON.stringify({ ok: true, changed, workspace, defaultWorkspace }), + ); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not change the workspace root", + ); + } + return; + } + // Daemon defaults: GET reads the saved document (header-free read-only, + // like /status and /model-router); PUT replaces it whole, restarts mecated + // on the new flags and rolls back to the previous document when the new + // ones refuse to start (mecated validates --default-model / + // --subagent-model fail-fast; its stderr excerpt is the 400 body). The + // active provider is NOT settable here — POST /providers/active owns it + // with its selectable-kind check — so the body's activeProvider is + // replaced by the current one before normalisation. + if (request.method === "GET" && requestURL.pathname === "/daemon-defaults") { + response.end( + JSON.stringify({ defaults: daemonDefaults, managedBy: "studio" }), + ); + return; + } + if (request.method === "PUT" && requestURL.pathname === "/daemon-defaults") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const input = JSON.parse( + (await readBody(request, 16_384)).toString("utf8"), + ); + const next = normalizeDaemonDefaults( + { + ...(input && typeof input === "object" && !Array.isArray(input) + ? input + : {}), + activeProvider: daemonDefaults.activeProvider, + }, + { configDir: mecatlConfigDir }, + ); + await queueRestart(async () => { + const previous = daemonDefaults; + applyDaemonDefaults(next); + await persistDaemonDefaults(next); + try { + await startMecatl(preferredKind()); + } catch (error) { + applyDaemonDefaults(previous); + await persistDaemonDefaults(previous); + await startMecatl(preferredKind()); + throw error; + } + }); + response.end(JSON.stringify({ ok: true, defaults: daemonDefaults })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not save the daemon defaults", + ); + } + return; + } + // Retention: POST /retention {main, child, scheduled: {maxAge?, maxCount?}, + // sweepCadence?, acknowledgeMainDeletion?} (see + // src/lib/retention-settings.mjs). Header-gated like every write. The + // document becomes mecated CLI flags on the restart; a start mecated refuses + // (it re-checks the main-deletion acknowledgement itself) rolls the previous + // document back — "no file" included — restarts on it, and surfaces the + // failure as the 400 body. Refused while an imported operator settings file + // is active: the controller passes no retention flags alongside that file. + // /status.retention is the read half. + if (request.method === "POST" && requestURL.pathname === "/retention") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + if (operatorSettingsActive) + throw new Error( + "Retention is managed by the imported operator settings file while it is active. Edit its retention: block instead.", + ); + const next = normalizeRetentionSettings( + JSON.parse((await readBody(request, 16_384)).toString("utf8")), + ); + await queueRestart(async () => { + const previous = retentionSettings; + retentionSettings = next; + await persistRetentionSettings(next); + try { + await startMecatl(preferredKind()); + } catch (error) { + retentionSettings = previous; + if (previous) await persistRetentionSettings(previous); + else await rm(retentionStateFile, { force: true }); + await startMecatl(preferredKind()); + throw new Error( + `${error.message || error} (previous retention policy restored)`, + ); + } + }); + response.end(JSON.stringify({ ok: true, retention: retentionStatus() })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the retention policy", + ); + } + return; + } + // Runtime settings: learning mode/sensitivity, the steer opt-out, the soul + // flags (src/lib/runtime-settings.mjs). GET reads the saved document plus + // the inherited settings.yaml values and the effective fold (header-gated: + // the soul path and candidate list name files on this machine); PUT + // replaces it whole, restarts mecated, and rolls the previous document + // back when the new flags/file make it refuse to start. Learning is + // refused (409) while an imported operator settings file is active — the + // controller passes no learning file alongside it, so a save would lie. + if (request.method === "GET" && requestURL.pathname === "/runtime-settings") { + response.end(JSON.stringify(await runtimeSettingsDocument())); + return; + } + if (request.method === "PUT" && requestURL.pathname === "/runtime-settings") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const next = normalizeRuntimeSettings( + JSON.parse((await readBody(request, 16_384)).toString("utf8")), + ); + if ( + operatorSettingsActive && + JSON.stringify(next.learning) !== + JSON.stringify(runtimeSettings.learning) + ) + throw Object.assign( + new Error( + "Learning mode and sensitivity are managed by the imported operator settings file while it is active. Edit its learning: block instead.", + ), + { statusCode: 409 }, + ); + if (next.soul.enabled && next.soul.file) + await validateSoulFile(next.soul.file); + await queueRestart(async () => { + const previous = runtimeSettings; + runtimeSettings = next; + await persistRuntimeState(next); + try { + await startMecatl(preferredKind()); + } catch (error) { + runtimeSettings = previous; + await persistRuntimeState(previous); + await startMecatl(preferredKind()); + throw new Error( + `${error.message || error} (previous runtime settings restored)`, + ); + } + }); + response.end(JSON.stringify({ ok: true, config: runtimeSettings })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the runtime settings", + ); + } + return; + } + // Accept the current soul as the drift baseline: ONE spawn with + // --approve-soul (mecated rewrites .sha256), then the flag is gone + // again. A failed start retries WITHOUT the flag so the daemon is never + // left down by an approval. A no-op for a driver-provenance soul + // (--soul-source-url) and while the soul is disabled — the UI offers it + // only for a user/project soul. + if (request.method === "POST" && requestURL.pathname === "/soul/approve") { + try { + if (!runtimeSettings.soul.enabled) + throw Object.assign( + new Error( + "The persona is disabled; enable it before accepting a baseline.", + ), + { statusCode: 409 }, + ); + await queueRestart(async () => { + approveSoulPending = true; + try { + await startMecatl(preferredKind()); + } catch (error) { + approveSoulPending = false; + await startMecatl(preferredKind()); + throw new Error( + `${error.message || error} (restarted without --approve-soul)`, + ); + } finally { + approveSoulPending = false; + } + }); + response.end(JSON.stringify({ ok: true, restarted: true })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not accept the persona baseline", + ); + } + return; + } + // Daemon options: the two memory stores, skill discovery, slash commands, + // MCP discovery (src/lib/daemon-options.mjs). GET reads the saved document + // plus the fallback locations and the directories this spawn resolved to + // (studio-header-gated: it names directories on this machine); PUT + // replaces it whole, restarts mecated, and rolls the previous document + // back when the new flags make it refuse to start. Every set directory is + // confined to the workspace / default root / mecatl config dir BEFORE + // anything is persisted or created. + if (request.method === "GET" && requestURL.pathname === "/daemon-options") { + response.end(JSON.stringify(daemonOptionsDocument())); + return; + } + if (request.method === "PUT" && requestURL.pathname === "/daemon-options") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw Object.assign( + new Error("Content-Type must be application/json"), + { statusCode: 415 }, + ); + const next = normalizeDaemonOptions( + JSON.parse((await readBody(request, 16_384)).toString("utf8")), + ); + await validateOptionDirs(next); + await queueRestart(async () => { + const previous = daemonOptions; + applyDaemonOptions(next); + await persistDaemonOptions(next); + try { + await startMecatl(preferredKind()); + } catch (error) { + applyDaemonOptions(previous); + await persistDaemonOptions(previous); + await startMecatl(preferredKind()); + throw new Error( + `${error.message || error} (previous daemon options restored)`, + ); + } + }); + response.end(JSON.stringify({ ok: true, options: daemonOptions })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the daemon options", + ); + } + return; + } + if (request.method === "GET" && requestURL.pathname === "/model-router") { + response.end( + JSON.stringify({ + config: modelRouterConfig, + managedBy: operatorSettingsActive ? "operator-settings" : "studio", + }), + ); + return; + } + if ( + request.method !== "POST" || + !["/mcp", "/model-router"].includes(requestURL.pathname) + ) { + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + return; + } + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) { + throw Object.assign(new Error("Content-Type must be application/json"), { + statusCode: 415, + }); + } + const input = JSON.parse( + (await readBody(request, 16_384)).toString("utf8"), + ); + if (requestURL.pathname === "/model-router") { + if (operatorSettingsActive) + throw new Error( + "Routing is managed by the imported operator settings. Update the complete settings file to preserve its aliases, slots, and guardrails.", + ); + const nextConfig = normalizeModelRouter(input); + await queueRestart(async () => { + const previousConfig = modelRouterConfig; + modelRouterConfig = nextConfig; + await persistModelRouter(nextConfig); + try { + await startMecatl(preferredKind()); + } catch (error) { + modelRouterConfig = previousConfig; + if (previousConfig) await persistModelRouter(previousConfig); + else + await Promise.all([ + rm(routerSettingsFile, { force: true }), + rm(routerStateFile, { force: true }), + ]); + await startMecatl(preferredKind()); + throw error; + } + }); + response.end(JSON.stringify({ ok: true, config: modelRouterConfig })); + return; + } + if (!/^[A-Za-z0-9_]+$/.test(input.name || "")) + throw new Error( + "Gateway name may contain only letters, numbers, and underscores", + ); + const parsed = validateGatewayURL(input.url, { + allowLoopbackHTTP: process.env.MECATL_ALLOW_INSECURE_LOOPBACK_MCP === "1", + }); + const token = + typeof input.token === "string" + ? input.token.trim().replace(/^Bearer\s+/i, "") + : ""; + const candidate = { name: input.name, url: parsed.toString(), token }; + await queueRestart(async () => { + const previousGateway = gateway; + gateway = candidate; + try { + await startMecatl(preferredKind()); + } catch (error) { + // Keep the provider usable and keep rejected gateway credentials out of + // controller state. The caller still receives the original handshake error. + gateway = previousGateway; + await startMecatl(preferredKind()); + throw error; + } + }); + response.end( + JSON.stringify({ + ok: true, + gateway: { name: gateway.name, url: gateway.url }, + }), + ); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Could not update the Mecatl controller", + ); + } +}); + +server.listen(8788, "127.0.0.1", async () => { + process.stdout.write("Mecatl local controller: http://127.0.0.1:8788\n"); + operatorSettingsActive = await hasOperatorSettings(); + modelRouterConfig = await loadModelRouter(); + runtimeSettings = await loadRuntimeSettings(); + permissionsConfig = await loadPermissions(); + diagnosticsOptions = await loadDiagnosticsOptions(); + storageSettings = await loadStorageSettings(); + // The saved daemon options BEFORE the root: applyWorkspace derives the + // skills/memory directories from both (an override, else the root's + // pinned/default location). + daemonOptions = await loadDaemonOptions(); + // The saved root (or the default) BEFORE the first spawn: the spawn + // flags, the trust probes and the derived directories all read it. + applyWorkspace(await loadWorkspace()); + retentionSettings = await loadRetentionSettings(); + applyDaemonDefaults(await loadDaemonDefaults()); + toolhiveReady = await detectToolhiveGateway(); + thvOnPath = await detectThvOnPath(); + process.stdout.write( + toolhiveReady + ? `ToolHive LLM gateway detected at ${toolhiveGatewayURL}\n` + : `ToolHive LLM gateway not reachable at ${toolhiveGatewayURL} (start it with "thv llm proxy start"${thvOnPath ? ", or from Settings → Model provider" : ""}); falling back to the offline mock\n`, + ); + // The DURABLE active-provider choice seeds the live selection unless + // MECATL_STUDIO_PROVIDER pins one (env stays authoritative). A saved kind + // that is not selectable right now (its auth.yaml block removed, its + // gateway down) is dropped rather than handed to a fail-fast boot, so the + // daemon comes up on the fallback instead of not at all. + if (!configuredProvider && daemonDefaults.activeProvider) { + const saved = daemonDefaults.activeProvider; + if (isSelectableProviderKind(saved, await listSelectableProviderNames())) { + activeProviderOverride = saved; + } else { + process.stderr.write( + `[daemon-defaults] saved active provider "${saved}" is not selectable right now; falling back\n`, + ); + applyDaemonDefaults({ ...daemonDefaults, activeProvider: null }); + } + } + try { + await startMecatl(preferredKind()); + } catch (error) { + if (!startupError) { + // Not via startupFailure (a gateway handshake failure, say): record + // it in the log file ourselves so the crash-at-start is on disk. + startupError = error.message || "mecated could not start"; + recordDaemonLog(`[supervisor] ${startupError}\n`); + } + process.stderr.write(`${startupError}\n`); + } +}); + +// Clean-exit reaping. The CRASH path needs none of this: the lifetime pipe's +// write end dies with this process — SIGKILL included — and mecated reads EOF +// and shuts itself down, so a controller crash can no longer orphan a daemon. +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, async () => { + shuttingDown = true; + if (restartTimer) clearTimeout(restartTimer); + await stopChild(); + server.close(() => process.exit(0)); + }); +} diff --git a/studio/src/app/api/auth/oidc/[action]/route.ts b/studio/src/app/api/auth/oidc/[action]/route.ts new file mode 100644 index 0000000000..aa191bdee8 --- /dev/null +++ b/studio/src/app/api/auth/oidc/[action]/route.ts @@ -0,0 +1,127 @@ +/** + * Remote OIDC login routes (requirement H3) — the server-tier half of + * Authorization Code + PKCE against an OIDC-protected external mecated. + * Tokens live in `src/lib/oidc-session.ts` (process memory) and never reach + * the browser (rule 3). Same origin-trust table as the proxy routes (rule 4's + * browser-facing half). + * + * GET /api/auth/oidc/status → sign-in state for the settings card + * GET /api/auth/oidc/start → 302 to the issuer's authorize URL + * (opened by the card's Sign in button — + * never without that user action, H3.3); + * `?mode=link` answers the URL as JSON + * instead for the card's "Copy sign-in + * link" (the `--no-browser` analogue: the + * link opens in any browser that can reach + * Studio's callback) + * GET /api/auth/oidc/callback → the registered redirect URI; verifies + * state + PKCE server-side, exchanges the + * code, renders a close-this-tab page + * POST /api/auth/oidc/logout → local sign-out + best-effort revocation + * POST /api/auth/oidc/confirm-discovery { profileHash } + * → confirm the RFC 9728-discovered profile + * the card showed (default-deny until then) + */ +import { + beginOidcLogin, + type CallbackOutcome, + completeOidcCallback, + confirmDiscoveredProfile, + oidcLoginStatus, + oidcSignOut, +} from "@/lib/oidc-session"; +import { requestIsTrusted } from "@/lib/request-trust"; + +type Context = { params: Promise<{ action: string }> }; + +const noStore = { "cache-control": "no-store" }; + +const forbidden = () => + Response.json({ error: "request origin is not allowed" }, { status: 403 }); + +const unknownAction = () => + Response.json({ error: "unknown auth action" }, { status: 404 }); + +export async function GET(request: Request, context: Context) { + if (!requestIsTrusted(request)) return forbidden(); + const { action } = await context.params; + if (action === "status") { + return Response.json(await oidcLoginStatus(), { headers: noStore }); + } + if (action === "start") { + try { + const { authorizationUrl, expiresAt } = await beginOidcLogin(); + if (new URL(request.url).searchParams.get("mode") === "link") { + return Response.json( + { authorizationUrl, expiresAt: new Date(expiresAt).toISOString() }, + { headers: noStore }, + ); + } + return new Response(null, { + status: 302, + headers: { ...noStore, location: authorizationUrl }, + }); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : "Could not start OIDC sign-in."; + return Response.json( + { error: message }, + { status: 400, headers: noStore }, + ); + } + } + if (action === "callback") { + const outcome = await completeOidcCallback( + new URL(request.url).searchParams, + ); + return callbackPage(outcome); + } + return unknownAction(); +} + +export async function POST(request: Request, context: Context) { + if (!requestIsTrusted(request)) return forbidden(); + const { action } = await context.params; + if (action === "logout") { + return Response.json(await oidcSignOut(), { headers: noStore }); + } + if (action === "confirm-discovery") { + const body = (await request.json().catch(() => null)) as { + profileHash?: unknown; + } | null; + const outcome = await confirmDiscoveredProfile(body?.profileHash); + return Response.json(outcome, { + status: outcome.ok ? 200 : 409, + headers: noStore, + }); + } + return unknownAction(); +} + +/** Mirrors the controller's gateway-OAuth callback page: a tiny same-origin + * HTML page that notifies the opener (the settings card listens for the + * `mecatl-oidc` message) and asks to be closed. The failure message is + * harness-authored or provider-bounded upstream, and escaped here anyway. */ +function callbackPage(outcome: CallbackOutcome): Response { + const headers = { + "content-type": "text/html; charset=utf-8", + // The authorization code rides this page's URL; keep it out of referrers. + "referrer-policy": "no-referrer", + ...noStore, + }; + const notify = (ok: boolean) => + ``; + if (outcome.ok) { + return new Response( + `Signed in to Mecatl

Signed in

You can close this tab and return to Mecatl Studio.

${notify(true)}`, + { headers }, + ); + } + const message = outcome.error.replace(/[<>&"']/g, ""); + return new Response( + `Mecatl sign-in failed

Could not sign in

${message}

${notify(false)}`, + { status: 400, headers }, + ); +} diff --git a/studio/src/app/api/mecatl-control/[...path]/route.ts b/studio/src/app/api/mecatl-control/[...path]/route.ts new file mode 100644 index 0000000000..3bb3061b37 --- /dev/null +++ b/studio/src/app/api/mecatl-control/[...path]/route.ts @@ -0,0 +1,14 @@ +import { proxyControl } from "@/lib/server-proxy"; + +type Context = { params: Promise<{ path: string[] }> }; + +const handle = async (request: Request, context: Context) => + proxyControl(request, (await context.params).path); + +export { + handle as DELETE, + handle as GET, + handle as PATCH, + handle as POST, + handle as PUT, +}; diff --git a/studio/src/app/api/mecatl/[...path]/route.ts b/studio/src/app/api/mecatl/[...path]/route.ts new file mode 100644 index 0000000000..e6dff5be20 --- /dev/null +++ b/studio/src/app/api/mecatl/[...path]/route.ts @@ -0,0 +1,14 @@ +import { proxyMecatl } from "@/lib/server-proxy"; + +type Context = { params: Promise<{ path: string[] }> }; + +const handle = async (request: Request, context: Context) => + proxyMecatl(request, (await context.params).path); + +export { + handle as DELETE, + handle as GET, + handle as PATCH, + handle as POST, + handle as PUT, +}; diff --git a/studio/src/app/api/palettes/route.test.ts b/studio/src/app/api/palettes/route.test.ts new file mode 100644 index 0000000000..468fcc19c6 --- /dev/null +++ b/studio/src/app/api/palettes/route.test.ts @@ -0,0 +1,64 @@ +import { resolve } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./route"; + +const FIXTURES = resolve( + import.meta.dirname, + "../../../../tests/fixtures/palettes", +); + +/** + * `/api/palettes` is the operator-palette read: same-origin gated like the + * proxies, per-request (no-store) so a changed STUDIO_PALETTE_DIR needs no + * rebuild, and never more than the validated documents in its body. + */ +describe("GET /api/palettes", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("answers an empty list when STUDIO_PALETTE_DIR is unset", async () => { + vi.stubEnv("STUDIO_PALETTE_DIR", ""); + const response = await GET( + new Request("http://localhost:3000/api/palettes"), + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ palettes: [] }); + }); + + it("serves the validated documents from the directory and logs the skipped file", async () => { + vi.stubEnv("STUDIO_PALETTE_DIR", FIXTURES); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const response = await GET( + new Request("http://localhost:3000/api/palettes"), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + palettes: Array<{ name: string }>; + }; + expect(body.palettes.map((p) => p.name)).toEqual(["midnight"]); + expect(JSON.stringify(body)).not.toContain("url("); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[palettes\] skipped broken\.json/), + ); + }); + + it("refuses a request from an origin Studio is not served from", async () => { + vi.stubEnv("STUDIO_PALETTE_DIR", FIXTURES); + const rebinding = await GET( + new Request("http://attacker.example/api/palettes"), + ); + expect(rebinding.status).toBe(403); + const csrf = await GET( + new Request("http://localhost:3000/api/palettes", { + headers: { origin: "https://evil.example" }, + }), + ); + expect(csrf.status).toBe(403); + expect(await csrf.json()).toEqual({ + error: "request origin is not allowed", + }); + }); +}); diff --git a/studio/src/app/api/palettes/route.ts b/studio/src/app/api/palettes/route.ts new file mode 100644 index 0000000000..c33bd0f24d --- /dev/null +++ b/studio/src/app/api/palettes/route.ts @@ -0,0 +1,29 @@ +import { loadOperatorPalettes } from "@/lib/operator-palettes"; +import { requestIsTrusted } from "@/lib/request-trust"; + +// force-dynamic: STUDIO_PALETTE_DIR is a runtime env var (Helm/Kubernetes), +// and the directory is re-read per request so an edited palette shows up +// without a rebuild — the same posture as /brand/logo. +export const dynamic = "force-dynamic"; + +/** + * `GET /api/palettes` → `{ palettes: PaletteDocument[] }`: the operator's + * palettes from `STUDIO_PALETTE_DIR`, validated server-side (broken files + * are skipped with a server log line and never described to the browser). + * Unset → `{ palettes: [] }`. Same-origin only, like the proxies. + */ +export async function GET(request: Request): Promise { + if (!requestIsTrusted(request)) { + return Response.json( + { error: "request origin is not allowed" }, + { status: 403 }, + ); + } + // A literal read: studio-config-reference.test.ts scans for exactly this + // form, so the knob cannot leave the in-app configuration reference. + const palettes = await loadOperatorPalettes(process.env.STUDIO_PALETTE_DIR); + return Response.json( + { palettes }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/studio/src/app/api/studio/about/route.test.ts b/studio/src/app/api/studio/about/route.test.ts new file mode 100644 index 0000000000..109671bbaa --- /dev/null +++ b/studio/src/app/api/studio/about/route.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./route"; + +/** + * The route behind Help & about's configuration reference. It reports WHICH + * names are set, never a value — the negative assertion below is the one + * that matters — and it is same-origin only, like every other Studio API + * route (a rebinding Host or a foreign Origin gets a 403). + */ + +const TRUSTED = "http://localhost:3000/api/studio/about"; + +beforeEach(() => { + // The default allowlist (localhost:3000 / 127.0.0.1:3000) applies. + vi.stubEnv("MECATL_STUDIO_PUBLIC_ORIGIN", ""); + vi.stubEnv("MECATL_BASE_URL", "http://127.0.0.1:9"); + vi.stubEnv("MECATL_AUTH_TOKEN", "sekrit"); + vi.stubEnv("MECATL_OIDC_ISSUER", ""); + vi.stubEnv("NEXT_PUBLIC_STUDIO_VERSION", "0.1.0"); + vi.stubEnv("NEXT_PUBLIC_SDK_VERSION", "0.2.0"); + vi.stubEnv("NEXT_PUBLIC_STUDIO_BUILD", "0.1.0+abc1234"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("GET /api/studio/about", () => { + it("answers the versions, the mode and which names are set — never a value", async () => { + const response = GET(new Request(TRUSTED)); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + const text = await response.text(); + const body = JSON.parse(text); + expect(body).toMatchObject({ + version: "0.1.0", + sdkVersion: "0.2.0", + build: "0.1.0+abc1234", + mode: "external", + }); + expect(body.configured.MECATL_BASE_URL).toBe(true); + expect(body.configured.MECATL_AUTH_TOKEN).toBe(true); + expect(body.configured.MECATL_OIDC_ISSUER).toBe(false); + expect(text).not.toContain("sekrit"); + expect(text).not.toContain("127.0.0.1:9"); + }); + + it("reports managed mode when MECATL_BASE_URL is unset", async () => { + vi.stubEnv("MECATL_BASE_URL", ""); + const body = await GET(new Request(TRUSTED)).json(); + expect(body.mode).toBe("managed"); + expect(body.configured.MECATL_BASE_URL).toBe(false); + }); + + it("refuses a request that reached a foreign Host (DNS rebinding)", async () => { + const response = GET(new Request("http://evil.example/api/studio/about")); + expect(response.status).toBe(403); + expect(await response.text()).not.toContain("sekrit"); + }); + + it("refuses a browser request from a foreign Origin (CSRF)", () => { + // undici's Request drops the forbidden `origin` header, which would make + // this vacuous; the handler reads only `url` + `headers.get`, so a plain + // object stands in for the browser's request. + const request = { + url: TRUSTED, + headers: new Map([["origin", "http://evil.example"]]), + } as unknown as Request; + expect(GET(request).status).toBe(403); + }); +}); diff --git a/studio/src/app/api/studio/about/route.ts b/studio/src/app/api/studio/about/route.ts new file mode 100644 index 0000000000..11afd0a751 --- /dev/null +++ b/studio/src/app/api/studio/about/route.ts @@ -0,0 +1,37 @@ +import { requestIsTrusted } from "@/lib/request-trust"; +import { studioBuild } from "@/lib/studio-build"; +import { + configuredEnvNames, + type StudioAbout, +} from "@/lib/studio-config-reference"; +import { sdkVersion, studioVersion } from "@/lib/studio-version"; + +// force-dynamic: the environment is a runtime fact of THIS process (Helm, +// a container, a shell), read per request so the answer is never a build's +// stale snapshot. +export const dynamic = "force-dynamic"; + +/** + * `GET /api/studio/about` → {@link StudioAbout}: Studio's own version, the + * SDK version and the build stamp, the managed/external server mode, and + * which of the configuration reference's environment variables THIS + * deployment has set — names and booleans only, never a value (a secret is + * reported exactly like any other name). Same-origin only, like the + * proxies: a foreign Host or Origin is a 403. + */ +export function GET(request: Request): Response { + if (!requestIsTrusted(request)) { + return Response.json( + { error: "request origin is not allowed" }, + { status: 403 }, + ); + } + const body: StudioAbout = { + version: studioVersion(), + sdkVersion: sdkVersion(), + build: studioBuild(), + mode: process.env.MECATL_BASE_URL?.trim() ? "external" : "managed", + configured: configuredEnvNames(process.env), + }; + return Response.json(body, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/studio/src/app/brand/logo/route.test.ts b/studio/src/app/brand/logo/route.test.ts new file mode 100644 index 0000000000..5f3a7149dd --- /dev/null +++ b/studio/src/app/brand/logo/route.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { GET } from "./route"; + +const mockReadFile = vi.hoisted(() => vi.fn()); +vi.mock("node:fs/promises", () => ({ readFile: mockReadFile })); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + mockReadFile.mockResolvedValue("stacklok logo"); +}); + +it("serves the fallback SVG when BRAND_LOGO_URL is not set", async () => { + vi.stubEnv("BRAND_LOGO_URL", ""); + const response = await GET(); + expect(response.headers.get("Content-Type")).toBe("image/svg+xml"); + expect(mockReadFile).toHaveBeenCalledWith( + `${process.cwd()}/public/stacklok-logo.svg`, + ); +}); + +it("proxies BRAND_LOGO_URL and forwards the upstream Content-Type", async () => { + vi.stubEnv("BRAND_LOGO_URL", "https://example.com/logo.png"); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response("PNG data", { headers: { "Content-Type": "image/png" } }), + ), + ); + const response = await GET(); + expect(response.headers.get("Content-Type")).toBe("image/png"); +}); + +it("defaults Content-Type to image/svg+xml when upstream omits it", async () => { + vi.stubEnv("BRAND_LOGO_URL", "https://example.com/logo.svg"); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("brand")); + controller.close(); + }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(stream, { status: 200 })), + ); + const response = await GET(); + expect(response.headers.get("Content-Type")).toBe("image/svg+xml"); +}); + +it("returns 404 when upstream returns a non-ok status", async () => { + vi.stubEnv("BRAND_LOGO_URL", "https://example.com/logo.svg"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 500 })), + ); + const response = await GET(); + expect(response.status).toBe(404); +}); + +it("returns 404 when fetch throws a network error", async () => { + vi.stubEnv("BRAND_LOGO_URL", "https://example.com/logo.svg"); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Network failure")), + ); + const response = await GET(); + expect(response.status).toBe(404); +}); diff --git a/studio/src/app/brand/logo/route.ts b/studio/src/app/brand/logo/route.ts new file mode 100644 index 0000000000..af7639cec8 --- /dev/null +++ b/studio/src/app/brand/logo/route.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +import * as fsPromises from "node:fs/promises"; + +// force-dynamic: BRAND_LOGO_URL is a runtime env var set via Helm/Kubernetes. +export const dynamic = "force-dynamic"; + +async function serveFallback(): Promise { + const content = await fsPromises.readFile( + `${process.cwd()}/public/stacklok-logo.svg`, + ); + return new Response(content, { + headers: { "Content-Type": "image/svg+xml" }, + }); +} + +export async function GET(): Promise { + const brandLogoUrl = process.env.BRAND_LOGO_URL; + if (!brandLogoUrl) return serveFallback(); + try { + const upstream = await fetch(brandLogoUrl); + if (!upstream.ok) return new Response(null, { status: 404 }); + const contentType = upstream.headers.get("Content-Type") ?? "image/svg+xml"; + return new Response(upstream.body, { + headers: { "Content-Type": contentType }, + }); + } catch { + return new Response(null, { status: 404 }); + } +} diff --git a/studio/src/app/error.tsx b/studio/src/app/error.tsx new file mode 100644 index 0000000000..41472d4901 --- /dev/null +++ b/studio/src/app/error.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { ErrorPage } from "@/components/error-page/error-page-client"; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function RootErrorPage({ error, reset }: ErrorProps) { + return ; +} diff --git a/studio/src/app/global-error.tsx b/studio/src/app/global-error.tsx new file mode 100644 index 0000000000..3d3eba6369 --- /dev/null +++ b/studio/src/app/global-error.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Inter } from "next/font/google"; +import { BrandLogo } from "@/components/brand-logo"; +import { Button } from "@/components/ui/button"; +import "./globals.css"; + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], +}); + +interface GlobalErrorProps { + reset: () => void; +} + +export default function GlobalError({ reset }: GlobalErrorProps) { + return ( + + +
+ + +

+ Something went wrong +

+ + +
+ + + ); +} diff --git a/studio/src/app/globals.css b/studio/src/app/globals.css new file mode 100644 index 0000000000..da5a722ba9 --- /dev/null +++ b/studio/src/app/globals.css @@ -0,0 +1,600 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +/* Tokens hold complete CSS color values — not bare HSL triplets. + This keeps overrides format-agnostic (hex, hsl, oklch all work). The named + palettes (Settings → Personalize → Palette; src/lib/palettes.ts) are the + `[data-palette="…"]` / `.dark[data-palette="…"]` block pairs after `.dark` + below: each overrides the accent + shell subset of these tokens, and + palettes-css.test.ts pins that every catalogue id has both blocks. */ +:root { + --background: hsl(0 0% 100%); + --foreground: hsl(240 10% 3.9%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(240 10% 3.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(240 10% 3.9%); + --primary: hsl(240 5.9% 10%); + --primary-foreground: hsl(0 0% 98%); + --secondary: hsl(240 4.8% 95.9%); + --secondary-foreground: hsl(240 5.9% 10%); + --muted: hsl(240 4.8% 95.9%); + --muted-foreground: hsl(240 3.8% 46.1%); + --accent: hsl(240 4.8% 95.9%); + --accent-foreground: hsl(240 5.9% 10%); + --destructive: hsl( + 0 72% 51% + ); /* darker than shadcn's default, per the design system */ + --destructive-foreground: hsl(0 0% 98%); + --destructive-strong: hsl( + 0 74% 40% + ); /* a darker red for icons over a destructive/10 tile */ + --border: hsl(240 5.9% 90%); + --input: hsl(240 5.9% 90%); + --ring: hsl(240 5% 65%); /* soft neutral focus ring — gray, not near-black */ + --radius: 0.5rem; + --sidebar: hsl(240 4.8% 98.5%); + --brand: hsl(161 94% 21%); /* #036a49 — Stacklok brand green */ + --brand-foreground: hsl(0 0% 100%); + --avatar-background: oklch(0.696 0 0 / 89.8%); + --nav-background: #18442e; + --nav-border: #265b41; + --nav-button-active-bg: #398560; + --nav-button-active-text: #ffffff; + + /* Shell band tokens — the workspace radial gradient and the top nav that + sits on it (workspace/layout.tsx, shell/top-nav.tsx). Promoted from + literals so a named palette can restyle the band; the nav colours are + the same in light and dark (the band is a fixed brand surface), only the + gradient deepens in dark. */ + --shell-gradient-start: #006652; + --shell-gradient-mid: #03433e; + --shell-gradient-end: #06202a; + --nav-pill-bg: #cadfd8; + --nav-pill-text: #03433e; + --nav-icon: #a5b8b4; + --nav-search-border: #6e807d; + --nav-search-text: #b4c0c1; + --nav-kbd-bg: #3f605a; + + /* Status, display, and link tokens (ADR-0018 convergence palette). + Complete colour values, never bare HSL triplets. */ + --success: hsl(142 71% 29%); /* green — succeeded / healthy state */ + --warning: hsl(35 92% 42%); /* amber — needs attention */ + --info: hsl(211 92% 43%); /* blue — informational */ + --title: hsl(240 5% 18%); /* serif page-title ink — softer than body text */ + --logo: hsl(0 0% 28%); /* wordmark / logo fill — neutral ink */ + --brand-label: hsl(161 94% 21%); /* brand-coloured label text */ + --link: hsl(211 92% 43%); /* inline hyperlink */ + --brand-ink: hsl( + 161 94% 21% + ); /* brand accent ink: nav/tab active, avatar initials */ + --input-icon: hsl(240 3.8% 46.1%); /* leading input icon ink (muted) */ + --badge-outline-border: hsl( + 240 5.9% 90% + ); /* outline badge border (= border) */ + --badge-outline-text: hsl( + 240 10% 3.9% + ); /* outline badge ink (= foreground) */ + --btn-primary: hsl(161 94% 21%); /* primary CTA fill (= brand) */ + --btn-primary-hover: #02543a; /* primary CTA hover */ + + /* Legacy shadcn sidebar family — consumed by src/components/ui/sidebar.tsx + (assistant panel). Keep until that primitive is retired. */ + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); +} + +.dark { + --background: hsl(240 6% 10%); + --foreground: hsl(0 0% 98%); + --card: hsl(240 6.1% 12.9%); /* cards are elevated in dark */ + --card-foreground: hsl(0 0% 98%); + --popover: hsl(240 6.3% 12.5%); + --popover-foreground: hsl(0 0% 98%); + --primary: hsl(210 34.5% 98%); + --primary-foreground: hsl(0 0% 9.1%); + --secondary: hsl(240 4% 15.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(240 6.3% 12.5%); + --muted-foreground: hsl(0 0% 63%); + --accent: hsl(240 4% 15.9%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(358.7 100% 69.6%); + --destructive-foreground: hsl(0 0% 98%); + --destructive-strong: hsl(358.7 78% 55%); + --border: hsl(0 0% 15.5%); + --input: hsl(0 0% 20.4%); + --ring: hsl(0 0% 45.2%); + --sidebar: hsl(240 6.3% 12.5%); + --brand-foreground: hsl(0 0% 100%); + --avatar-background: oklch(0.696 0 0 / 89.8%); + --nav-background: #0f2e1e; + --nav-border: #1a4430; + --nav-button-active-bg: #2d6b4a; + --nav-button-active-text: #f0f0f0; + + /* Shell band tokens — dark deepens each gradient stop so the shell recedes + behind the dark card; the nav colours stay the fixed brand values. */ + --shell-gradient-start: #023d31; + --shell-gradient-mid: #022723; + --shell-gradient-end: #02141b; + --nav-pill-bg: #cadfd8; + --nav-pill-text: #03433e; + --nav-icon: #a5b8b4; + --nav-search-border: #6e807d; + --nav-search-text: #b4c0c1; + --nav-kbd-bg: #3f605a; + + /* Status, display, and link tokens — dark-mode variants. */ + --success: hsl(142 61% 55%); /* lighter green for dark surfaces */ + --warning: hsl(35 92% 62%); /* lighter amber */ + --info: hsl(211 92% 66%); /* lighter blue */ + --title: hsl(0 0% 74%); /* serif page-title ink, softer than body */ + --logo: hsl(0 0% 58%); /* wordmark / logo fill — neutral ink */ + --brand-label: hsl(158 64% 52%); /* brand label text on dark */ + --link: hsl(211 92% 66%); /* inline hyperlink on dark */ + --brand-ink: hsl(142 50% 60%); /* softer brand ink on dark */ + --input-icon: hsl(0 0% 100%); /* near-white icon ink on dark */ + --badge-outline-border: hsl(0 0% 32%); /* lighter outline on dark surfaces */ + --badge-outline-text: hsl(0 0% 80%); /* dimmed outline ink on dark */ + --btn-primary: #024c34; /* darker primary CTA on dark */ + --btn-primary-hover: #013826; /* dark hover */ + + /* Legacy shadcn sidebar family — dark variants. */ + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); +} + +/* ── Named palettes ────────────────────────────────────────────────────────── + One light + one dark block per catalogue id in src/lib/palettes.ts, applied + as `data-palette` on (the boot script in the root layout sets it + before first paint; PaletteProvider keeps it in step). A palette overrides + ONLY the accent + shell subset of the tokens above — surfaces, text and the + semantic status colours stay — and both blocks of a pair define the SAME + token set, because `[data-palette]` (0-1-0) ties `.dark` (0-1-0) and wins + by source order alone, so the dark block (0-2-0) must restate every token + the light block touched. These blocks MUST stay after `:root` and `.dark` + (palettes-css.test.ts pins the order and the set parity). + Values follow mecatui's built-ins (cmd/mecatui/theme/builtins.go), darkened + where an accent has to carry small text on white. */ + +/* aztec — jade, turquoise and gold on obsidian (mecatui's default). */ +[data-palette="aztec"] { + --brand: #0f7f6c; + --brand-ink: #0f7f6c; + --brand-label: #0f7f6c; + --btn-primary: #0f7f6c; + --btn-primary-hover: #0b6656; + --link: #157a83; + --info: #157a83; + --nav-background: #15201c; + --nav-border: #2e4039; + --nav-button-active-bg: #2a9d8f; + --shell-gradient-start: #1f6b5c; + --shell-gradient-mid: #15302a; + --shell-gradient-end: #0e1311; + --nav-pill-bg: #bfe6dc; + --nav-pill-text: #0e2a24; + --nav-icon: #8a998f; + --nav-search-border: #4b6a60; + --nav-search-text: #b7c4bd; + --nav-kbd-bg: #2e4039; +} +.dark[data-palette="aztec"] { + --brand: #1fb39a; + --brand-ink: #4fd1b8; + --brand-label: #4fd1b8; + --btn-primary: #12866f; + --btn-primary-hover: #0e6b59; + --link: #5cc8d0; + --info: #5cc8d0; + --nav-background: #0e1311; + --nav-border: #1e2a25; + --nav-button-active-bg: #1c6b5c; + --shell-gradient-start: #143d33; + --shell-gradient-mid: #0f1f1a; + --shell-gradient-end: #0a0f0d; + --nav-pill-bg: #a8ddd0; + --nav-pill-text: #0e2a24; + --nav-icon: #8a998f; + --nav-search-border: #3d5a50; + --nav-search-text: #a9b8b1; + --nav-kbd-bg: #26362f; +} + +/* mono — neutral greys with a blue accent. */ +[data-palette="mono"] { + --brand: #2563eb; + --brand-ink: #2563eb; + --brand-label: #2563eb; + --btn-primary: #2563eb; + --btn-primary-hover: #1d4ed8; + --link: #2563eb; + --info: #2563eb; + --nav-background: #1f1f1f; + --nav-border: #3a3a3a; + --nav-button-active-bg: #3f3f46; + --shell-gradient-start: #3a3a3a; + --shell-gradient-mid: #232323; + --shell-gradient-end: #141414; + --nav-pill-bg: #e4e4e7; + --nav-pill-text: #18181b; + --nav-icon: #a1a1aa; + --nav-search-border: #71717a; + --nav-search-text: #c4c4c8; + --nav-kbd-bg: #3f3f46; +} +.dark[data-palette="mono"] { + --brand: #3b82f6; + --brand-ink: #60a5fa; + --brand-label: #60a5fa; + --btn-primary: #1d4ed8; + --btn-primary-hover: #1e40af; + --link: #60a5fa; + --info: #60a5fa; + --nav-background: #0b0b0b; + --nav-border: #262626; + --nav-button-active-bg: #2f2f2f; + --shell-gradient-start: #262626; + --shell-gradient-mid: #161616; + --shell-gradient-end: #0b0b0b; + --nav-pill-bg: #d4d4d8; + --nav-pill-text: #0b0b0b; + --nav-icon: #8a8a8a; + --nav-search-border: #4a4a4a; + --nav-search-text: #b5b5b5; + --nav-kbd-bg: #2a2a2a; +} + +/* solar — warm and light-leaning (solarized gold, orange and blue). */ +[data-palette="solar"] { + --brand: #9a7300; + --brand-ink: #8a6800; + --brand-label: #8a6800; + --btn-primary: #9a7300; + --btn-primary-hover: #7f5e00; + --link: #cb4b16; + --info: #1f6fa8; + --nav-background: #5c4300; + --nav-border: #7a5c0c; + --nav-button-active-bg: #9a7300; + --shell-gradient-start: #a77b0a; + --shell-gradient-mid: #6b4f0a; + --shell-gradient-end: #3f3008; + --nav-pill-bg: #f3e3b5; + --nav-pill-text: #3f3008; + --nav-icon: #d9c48a; + --nav-search-border: #a8925a; + --nav-search-text: #e6d8b4; + --nav-kbd-bg: #7a5c0c; +} +.dark[data-palette="solar"] { + --brand: #b58900; + --brand-ink: #e0b52e; + --brand-label: #e0b52e; + --btn-primary: #8a6800; + --btn-primary-hover: #705400; + --link: #f0884d; + --info: #5fb0ee; + --nav-background: #191404; + --nav-border: #3a2c08; + --nav-button-active-bg: #6b4f0a; + --shell-gradient-start: #4a3708; + --shell-gradient-mid: #2c2206; + --shell-gradient-end: #191404; + --nav-pill-bg: #e8d59a; + --nav-pill-text: #2c2206; + --nav-icon: #b8a878; + --nav-search-border: #6b5a2a; + --nav-search-text: #d6c89a; + --nav-kbd-bg: #3a2c08; +} + +/* Maps CSS tokens → Tailwind utility classes. + bg-background, text-foreground, border-border, bg-nav-background, etc. */ +@theme inline { + --font-sans: var(--font-inter); + --font-serif: var(--font-merriweather); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-destructive-strong: var(--destructive-strong); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-avatar-background: var(--avatar-background); + --color-nav-background: var(--nav-background); + --color-nav-border: var(--nav-border); + --color-nav-button-active-bg: var(--nav-button-active-bg); + --color-nav-button-active-text: var(--nav-button-active-text); + --color-brand: var(--brand); + --color-brand-foreground: var(--brand-foreground); + + /* Convergence palette → utilities: bg-success, text-warning, text-info, + text-title, text-logo, text-brand-label, text-link, etc. */ + --color-success: var(--success); + --color-warning: var(--warning); + --color-info: var(--info); + --color-title: var(--title); + --color-logo: var(--logo); + --color-brand-label: var(--brand-label); + --color-link: var(--link); + --color-brand-ink: var(--brand-ink); + --color-input-icon: var(--input-icon); + --color-badge-outline-border: var(--badge-outline-border); + --color-badge-outline-text: var(--badge-outline-text); + --color-btn-primary: var(--btn-primary); + --color-btn-primary-hover: var(--btn-primary-hover); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply text-foreground; + font-family: var(--font-inter), sans-serif; + } + /* Paint the canvas AND body with the shell gradient's terminal colour: + iOS standalone computes dvh stale at launch (self-corrects on first + scroll), leaving a strip beside/below the app, and overscroll exposes + the canvas too. Every visible surface (the card, panels, dialogs) + declares its own bg-background, so nothing reads body's background — + matching the gradient's end keeps the stale strip invisible. The token + already carries the dark and per-palette values. */ + html, + body { + background-color: var(--shell-gradient-end); + } + /* Mobile is read at arm's length — bump the default root size past + native body text (~17pt). The Interface scale slider multiplies + through --ui-scale rather than replacing the base. */ + html { + font-size: calc(100% * var(--ui-scale, 1)); + } + @media (max-width: 499px) { + html { + font-size: calc(18px * var(--ui-scale, 1)); + } + } +} + +@layer utilities { + .text-page-title { + font-family: var(--font-merriweather), serif; + font-size: 34px; + line-height: 42px; + font-weight: 300; + letter-spacing: -0.85px; + } +} + +[data-sonner-toaster] { + --normal-bg: var(--popover); + --normal-text: var(--popover-foreground); + --normal-border: var(--border); +} + +/* Tiptap / ProseMirror editor styles */ +.tiptap-editor .ProseMirror { + outline: none; + min-height: 100%; + padding: 1.5rem; + color: var(--foreground); + font-size: 0.9375rem; + line-height: 1.75; +} + +.tiptap-editor .ProseMirror h1 { + font-size: 1.75rem; + font-weight: 700; + line-height: 1.25; + margin: 1.5rem 0 0.75rem; +} + +.tiptap-editor .ProseMirror h2 { + font-size: 1.35rem; + font-weight: 600; + line-height: 1.3; + margin: 1.25rem 0 0.5rem; +} + +.tiptap-editor .ProseMirror h3 { + font-size: 1.1rem; + font-weight: 600; + line-height: 1.4; + margin: 1rem 0 0.4rem; +} + +.tiptap-editor .ProseMirror p { + margin: 0.5rem 0; +} + +.tiptap-editor .ProseMirror ul, +.tiptap-editor .ProseMirror ol { + margin: 0.5rem 0; + padding-left: 1.5rem; +} + +.tiptap-editor .ProseMirror ul { + list-style-type: disc; +} + +.tiptap-editor .ProseMirror ol { + list-style-type: decimal; +} + +.tiptap-editor .ProseMirror li { + margin: 0.2rem 0; +} + +.tiptap-editor .ProseMirror blockquote { + border-left: 3px solid + color-mix(in oklab, var(--muted-foreground) 30%, transparent); + padding-left: 1rem; + margin: 0.75rem 0; + color: var(--muted-foreground); + font-style: italic; +} + +.tiptap-editor .ProseMirror code { + background: var(--muted); + border-radius: 0.25rem; + padding: 0.15rem 0.4rem; + font-size: 0.85em; + font-family: ui-monospace, monospace; +} + +.tiptap-editor .ProseMirror pre { + background: hsl(240 10% 10%); + color: hsl(0 0% 90%); + border-radius: 0.5rem; + padding: 1rem; + margin: 0.75rem 0; + overflow-x: auto; + font-size: 0.85em; + font-family: ui-monospace, monospace; + line-height: 1.6; +} + +.tiptap-editor .ProseMirror pre code { + background: transparent; + padding: 0; + color: inherit; +} + +.tiptap-editor .ProseMirror strong { + font-weight: 600; +} + +.tiptap-editor .ProseMirror em { + font-style: italic; +} + +.tiptap-editor .ProseMirror hr { + border: none; + border-top: 1px solid var(--border); + margin: 1rem 0; +} + +.tiptap-editor .ProseMirror a { + color: var(--link); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Placeholder */ +.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--muted-foreground); + pointer-events: none; + float: left; + height: 0; +} + +/* Chat composer: a plain-text-style single field (no rich formatting). + Mirrors the old transparent-textarea look — 14px, tight leading, muted + placeholder — so swapping in ProseMirror keeps the composer identical. */ +.composer-editor .ProseMirror { + outline: none; + font-size: 0.875rem; + line-height: 1.5rem; + color: var(--foreground); + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + /* Growth policy (the TUI's input box): rests at three rows, grows with + content to eight, then scrolls inside itself so a long draft never + pushes the toolbar or the send button off-screen. Rows map 1:1 onto + the 1.5rem line-height; a caller's `rows` / `compact` reach here as + --composer-min-rows on the .composer-editor wrapper (composer-frame.ts). */ + min-height: calc(var(--composer-min-rows, 3) * 1.5rem); + max-height: calc(8 * 1.5rem); + overflow-y: auto; +} + +.composer-editor .ProseMirror p { + margin: 0; +} + +.composer-editor .ProseMirror p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: color-mix(in oklab, var(--muted-foreground) 60%, transparent); + pointer-events: none; + float: left; + height: 0; +} + +/* Mobile: the docked composer is a single native-messaging line (+, field, + mic/send), so it rests at one row whatever the caller asked for. Set on + the editor element itself, so it wins over the wrapper's inline + --composer-min-rows; the eight-row cap still applies. */ +@media (max-width: 499px) { + .composer-editor .ProseMirror { + --composer-min-rows: 1; + } +} + +/* Mobile: small UI glyphs step up one notch for touch legibility. Unlayered + on purpose — it must override the size-* utilities, which live in a + cascade layer; larger deliberate icon sizes are untouched. */ +@media (max-width: 499px) { + svg.lucide.size-4, + svg.lucide.size-\[17px\] { + width: 1.25rem; + height: 1.25rem; + } + svg.lucide.size-3\.5 { + width: 1.125rem; + height: 1.125rem; + } +} + +/* Thinking-indicator dots: Tailwind's animate-bounce moves 25% of the + element's own height — invisible on a 6px dot. A fixed 5px hop with a + squash-and-stretch easing reads as an actual bounce. */ +@keyframes thinking-bounce { + 0%, + 100% { + transform: translateY(0); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + 50% { + transform: translateY(-5px); + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} diff --git a/studio/src/app/icon.test.ts b/studio/src/app/icon.test.ts new file mode 100644 index 0000000000..7f746b8b2f --- /dev/null +++ b/studio/src/app/icon.test.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) Stacklok, Inc. All rights reserved. + +import { expect, it, vi } from "vitest"; +import { GET } from "./icon"; + +it("returns a Response when FAVICON_URL is not set", async () => { + vi.stubEnv("FAVICON_URL", ""); + await expect(GET()).resolves.toBeInstanceOf(Response); +}); + +it("returns a Response when FAVICON_URL is set", async () => { + vi.stubEnv("FAVICON_URL", "https://www.google.com/favicon.ico"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(new Uint8Array([0]))), + ); + await expect(GET()).resolves.toBeInstanceOf(Response); +}); diff --git a/studio/src/app/icon.ts b/studio/src/app/icon.ts new file mode 100644 index 0000000000..8b92b99787 --- /dev/null +++ b/studio/src/app/icon.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) Stacklok, Inc. All rights reserved. + +import * as fs from "node:fs"; + +// force-dynamic prevents Next.js from caching this route at build time. +// FAVICON_URL is a runtime environment variable — it is set via Helm values +// and must be read on each request, not baked into the build output. +export const dynamic = "force-dynamic"; + +async function customFavicon(url: string): Promise { + const upstream = await fetch(url); + const body = await upstream.arrayBuffer(); + return new Response(body, { + headers: { + "Content-Type": upstream.headers.get("Content-Type") ?? "image/x-icon", + }, + }); +} + +function defaultFavicon(): Response { + const pngContent = fs.readFileSync( + `${process.cwd()}/public/stacklok-favicon.png`, + ); + return new Response(pngContent, { + headers: { "Content-Type": "image/png" }, + }); +} + +export async function GET(): Promise { + const faviconUrl = process.env.FAVICON_URL; + return faviconUrl ? customFavicon(faviconUrl) : defaultFavicon(); +} + +export default GET; diff --git a/studio/src/app/layout.tsx b/studio/src/app/layout.tsx new file mode 100644 index 0000000000..debcc48d42 --- /dev/null +++ b/studio/src/app/layout.tsx @@ -0,0 +1,80 @@ +import type { Metadata, Viewport } from "next"; +import { Inter, Merriweather } from "next/font/google"; +import { ClientProviders } from "@/components/client-providers"; +import { PaletteBootScript } from "@/components/palette-boot-script"; +import { ServerProviders } from "@/components/server-providers"; +import { resolveDefaultPalette } from "@/lib/palettes"; +import "./globals.css"; + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], +}); + +const merriweather = Merriweather({ + variable: "--font-merriweather", + subsets: ["latin"], + weight: ["300", "400", "700"], +}); + +export const metadata: Metadata = { + title: { + template: "%s — Mecatl Studio", + default: "Mecatl Studio", + }, + description: "The web workspace for the Mecatl agent harness", + icons: { + apple: "/apple-touch-icon.png", + }, + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "Studio", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#03433e" }, + { media: "(prefers-color-scheme: dark)", color: "#02141b" }, + ], + viewportFit: "cover", + // App-like surface: pinch-zoom off (text sizes stay OS-controlled). + maximumScale: 1, + userScalable: false, + // Android Chrome's default keeps the LAYOUT viewport fixed under the + // on-screen keyboard (only the visual viewport shrinks), which buries the + // docked composer behind it. resizes-content restores layout resize so + // h-dvh — and the composer with it — shrinks above the keyboard. iOS + // ignores this key. + interactiveWidget: "resizes-content", +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + // The operator's palette pin (the MECATUI_THEME analogue): a browser with + // no stored choice gets this palette. Read where the page renders — like + // BRAND_NAME, a statically prerendered route captures it at build time. + const defaultPalette = resolveDefaultPalette(process.env.BRAND_PALETTE); + return ( + + + {/* Blocking: puts the stored-or-default palette on before the + first paint, the way next-themes' own script does for .dark. */} + + + + + + {children} + + + + + ); +} diff --git a/studio/src/app/manifest.test.ts b/studio/src/app/manifest.test.ts new file mode 100644 index 0000000000..8e37d6485a --- /dev/null +++ b/studio/src/app/manifest.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import manifest from "./manifest"; + +/** + * The PWA share target lands shared content in the chat route as the + * `?prompt=` deep link's PREFILL-ONLY form: a GET to `/workspace/chat` whose + * text/title/url map onto the names `resolveChatSeed` reads, and never a + * `send` parameter — a share pre-fills the composer, the user presses Enter. + */ +describe("manifest share_target", () => { + it("shares into the chat route as a prefill-only seed", () => { + const target = manifest().share_target; + expect(target).toEqual({ + action: "/workspace/chat", + method: "GET", + params: { text: "prompt", title: "title", url: "url" }, + }); + expect(Object.values(target?.params ?? {})).not.toContain("send"); + }); + + it("keeps the standalone app opening on the chat", () => { + expect(manifest().start_url).toBe("/workspace/chat"); + }); +}); diff --git a/studio/src/app/manifest.ts b/studio/src/app/manifest.ts new file mode 100644 index 0000000000..06fc8f8fce --- /dev/null +++ b/studio/src/app/manifest.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) Stacklok, Inc. All rights reserved. + +import type { MetadataRoute } from "next"; + +// Deliberately NO service worker: Studio is a client for a live local +// `mecated` daemon, so offline caching would lie about live state (sessions, +// runs, schedules). Installability no longer requires a service worker on +// Chrome/Android, and never did on iOS — the manifest alone is enough. +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Mecatl Studio", + short_name: "Studio", + description: "The web workspace for the Mecatl agent harness", + start_url: "/workspace/chat", + scope: "/", + display: "standalone", + background_color: "#03433e", + theme_color: "#03433e", + // Share to the installed app: the shared text/title/url land in the + // chat route as `?prompt=&title=&url=`, PREFILL-ONLY — a share pre-fills + // the composer and the user presses Enter (see lib/chat-seed.ts; a + // share never carries `send=1`). + share_target: { + action: "/workspace/chat", + method: "GET", + params: { text: "prompt", title: "title", url: "url" }, + }, + icons: [ + { + src: "/icon-192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icon-512.png", + sizes: "512x512", + type: "image/png", + }, + { + src: "/icon-maskable-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/studio/src/app/not-found.test.tsx b/studio/src/app/not-found.test.tsx new file mode 100644 index 0000000000..41cb9f6c54 --- /dev/null +++ b/studio/src/app/not-found.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import NotFound from "./not-found"; + +describe("NotFound (root)", () => { + it("displays a page not found heading", async () => { + render(await NotFound()); + + expect( + screen.getByRole("heading", { name: /page not found/i }), + ).toBeVisible(); + }); + + it("displays a generic error message", async () => { + render(await NotFound()); + + expect( + screen.getByText(/the page you're looking for doesn't exist/i), + ).toBeVisible(); + }); + + it("has a link home", async () => { + render(await NotFound()); + + const link = screen.getByRole("link", { name: /go home/i }); + expect(link).toHaveAttribute("href", "/workspace/chat"); + }); + + it("does not have a back button", async () => { + render(await NotFound()); + + expect( + screen.queryByRole("button", { name: /back/i }), + ).not.toBeInTheDocument(); + }); + + it("displays the brand header", async () => { + render(await NotFound()); + + expect(screen.getByRole("banner")).toBeVisible(); + }); +}); diff --git a/studio/src/app/not-found.tsx b/studio/src/app/not-found.tsx new file mode 100644 index 0000000000..2ac3da25da --- /dev/null +++ b/studio/src/app/not-found.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import { ErrorPageLayout } from "@/components/error-page/error-page"; +import { Button } from "@/components/ui/button"; + +export default async function NotFound() { + return ( +
+
+ + {/* The stock wordmark asset is white, so it is rendered as a mask + tinted with the theme's --logo token (same as the shell sidebar). */} +
+
+ + Go home + + } + > + The page you're looking for doesn't exist or has been moved. + +
+
+ ); +} diff --git a/studio/src/app/page.tsx b/studio/src/app/page.tsx new file mode 100644 index 0000000000..39aab2bc67 --- /dev/null +++ b/studio/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/workspace/chat"); +} diff --git a/studio/src/app/workspace/_components/chat-input-mcp-insert.test.tsx b/studio/src/app/workspace/_components/chat-input-mcp-insert.test.tsx new file mode 100644 index 0000000000..f9197b5747 --- /dev/null +++ b/studio/src/app/workspace/_components/chat-input-mcp-insert.test.tsx @@ -0,0 +1,223 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { McpPromptView, McpRenderedPrompt } from "@/lib/harness/mcp"; +import { ShortcutsProvider } from "@/lib/shortcuts/use-shortcuts"; +import { ChatInput } from "./chat-input"; +import { + MCP_INSERT_LABEL, + requestOpenMcpPicker, + resetMcpPickerOpener, +} from "./mcp-composer-insert"; +import { PROMPT_PICKER_TITLE } from "./mcp-prompt-picker"; +import { RESOURCE_PICKER_TITLE } from "./mcp-resource-picker"; + +/** + * "Insert from MCP" through the rendered ChatInput (the TUI's f8 / ctrl+r + * pickers): the toolbar entry exists ONLY when the daemon grants + * `capabilities.mcp`; picking a prompt appends the rendered text AFTER the + * existing draft, as paragraphs separated by a blank line, and sends nothing; + * the `mcp.prompts` chord opens the picker from the composer; an outside + * open request (`requestOpenMcpPicker`, the slash built-ins' path) reaches + * the mounted composer and is refused while the capability is off. + */ + +const runtime = vi.hoisted(() => ({ + serverCapabilities: {} as Record, +})); +vi.mock("@/features/agent/runtime-status", () => ({ + useRuntimeStatus: () => ({ + connected: true, + serverCapabilities: runtime.serverCapabilities, + }), + useOptionalRuntimeStatus: () => ({ + connected: true, + serverCapabilities: runtime.serverCapabilities, + }), +})); + +const mcp = vi.hoisted(() => ({ + listPrompts: vi.fn<() => Promise>(), + getPrompt: vi.fn<() => Promise>(), + listResources: vi.fn<() => Promise>(), +})); +vi.mock("@/lib/harness/mcp", async (importOriginal) => ({ + ...(await importOriginal()), + listHarnessMcpPrompts: () => mcp.listPrompts(), + getHarnessMcpPrompt: () => mcp.getPrompt(), + listHarnessMcpResources: () => mcp.listResources(), +})); + +// jsdom lays nothing out: ProseMirror's scroll-into-view after a focus asks +// the selection for its rects, so give it empty ones. +const zeroRect = () => + ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}), + }) as DOMRect; +for (const proto of [Element.prototype, Range.prototype]) { + if (!proto.getClientRects) { + proto.getClientRects = () => [] as unknown as DOMRectList; + } + if (!proto.getBoundingClientRect) { + proto.getBoundingClientRect = zeroRect; + } +} + +const greet: McpPromptView = { + server: "fixture-mcp", + name: "greet", + title: "Greet", + description: "", + arguments: [], +}; + +afterEach(() => { + runtime.serverCapabilities = {}; + mcp.listPrompts.mockReset(); + mcp.getPrompt.mockReset(); + mcp.listResources.mockReset(); + resetMcpPickerOpener(); +}); + +async function renderComposer(props: Parameters[0] = {}) { + const utils = render( + + + , + ); + const dom = await waitFor(() => { + const el = utils.container.querySelector(".ProseMirror"); + if (!el) throw new Error("editor not mounted yet"); + return el; + }); + return { ...utils, dom }; +} + +/** The composer's paragraphs, one string each ("" for a blank line). */ +const paragraphs = (dom: HTMLElement) => + Array.from(dom.querySelectorAll("p")).map((p) => p.textContent ?? ""); + +describe("ChatInput — Insert from MCP", () => { + it("offers no MCP entry and refuses outside open requests without the mcp capability", async () => { + await renderComposer(); + expect(screen.queryByRole("button", { name: MCP_INSERT_LABEL })).toBeNull(); + expect(requestOpenMcpPicker("prompt")).toBe(false); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("appends the rendered prompt after the draft as paragraphs, and sends nothing", async () => { + runtime.serverCapabilities = { mcp: true }; + mcp.listPrompts.mockResolvedValue([greet]); + mcp.getPrompt.mockResolvedValue({ + description: "", + messages: [{ role: "user", text: "Hello there\nsecond line" }], + }); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { dom } = await renderComposer({ + onSend, + initialText: "Look at this:", + }); + await waitFor(() => expect(paragraphs(dom)).toEqual(["Look at this:"])); + + await user.click(screen.getByRole("button", { name: MCP_INSERT_LABEL })); + fireEvent.click(await screen.findByRole("menuitem", { name: /^Prompt/ })); + expect( + await screen.findByRole("dialog", { name: PROMPT_PICKER_TITLE }), + ).toBeInTheDocument(); + fireEvent.click(await screen.findByTestId("mcp-prompt-row")); + fireEvent.click( + await screen.findByRole("button", { name: "Insert into message" }), + ); + + await waitFor(() => + expect(paragraphs(dom)).toEqual([ + "Look at this:", + "", + "user: Hello there", + "second line", + ]), + ); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: PROMPT_PICKER_TITLE }), + ).toBeNull(), + ); + expect(onSend).not.toHaveBeenCalled(); + // The picker never lists resources on a prompt pick (one RPC per picker). + expect(mcp.listResources).not.toHaveBeenCalled(); + }); + + it("opens the prompt picker from the composer on the mcp.prompts chord", async () => { + runtime.serverCapabilities = { mcp: true }; + mcp.listPrompts.mockResolvedValue([]); + const { dom } = await renderComposer(); + act(() => { + dom.focus(); + }); + const event = new KeyboardEvent("keydown", { + key: "'", + metaKey: true, + bubbles: true, + cancelable: true, + }); + act(() => { + dom.dispatchEvent(event); + }); + expect(event.defaultPrevented).toBe(true); + expect( + await screen.findByRole("dialog", { name: PROMPT_PICKER_TITLE }), + ).toBeInTheDocument(); + }); + + it("offers the two pickers in the mobile options sheet too", async () => { + runtime.serverCapabilities = { mcp: true }; + mcp.listResources.mockResolvedValue([]); + await renderComposer(); + fireEvent.click(screen.getByRole("button", { name: "Composer options" })); + fireEvent.click( + await screen.findByRole("button", { name: "Insert an MCP resource" }), + ); + expect( + await screen.findByRole("dialog", { name: RESOURCE_PICKER_TITLE }), + ).toBeInTheDocument(); + }); + + it("hides the mobile rows without the capability", async () => { + await renderComposer(); + fireEvent.click(screen.getByRole("button", { name: "Composer options" })); + expect(await screen.findByText("Add a file")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Insert an MCP prompt" }), + ).toBeNull(); + }); + + it("opens the resource picker for an outside request while the capability is on", async () => { + runtime.serverCapabilities = { mcp: true }; + mcp.listResources.mockResolvedValue([]); + await renderComposer(); + let accepted = false; + act(() => { + accepted = requestOpenMcpPicker("resource"); + }); + expect(accepted).toBe(true); + expect( + await screen.findByRole("dialog", { name: RESOURCE_PICKER_TITLE }), + ).toBeInTheDocument(); + expect(mcp.listPrompts).not.toHaveBeenCalled(); + }); +}); diff --git a/studio/src/app/workspace/_components/chat-input-mode-cycle.test.tsx b/studio/src/app/workspace/_components/chat-input-mode-cycle.test.tsx new file mode 100644 index 0000000000..755751c9e6 --- /dev/null +++ b/studio/src/app/workspace/_components/chat-input-mode-cycle.test.tsx @@ -0,0 +1,236 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatInput, ModeSelector } from "./chat-input"; + +/** + * The composer's permission-mode affordances, driven through the rendered + * ChatInput (the paste test proved a synthetic event on the ProseMirror DOM + * reaches the editor's capture-phase listener): + * + * - ⇧Tab in the editor cycles Manual → Plan → Accept edits → Manual and + * claims the key (preventDefault), so focus never leaves the editor; a bare + * Tab, a disabled composer and a streaming run without the deferred-switch + * contract all leave the key to the browser. + * - The Mode pill stays ENABLED while streaming under `modeSwitchDeferred`, + * reads " · pending" for a held switch, and carries the mode's + * colour cue; the composer box gets the matching tint. + * + * The decision table itself (`resolveModeCycleKey`) is unit tested in + * lib/permission-mode.test.ts; this file proves the wiring. + */ + +// jsdom lays nothing out: ProseMirror's scroll-into-view after a focus asks +// the selection for its rects, so give it empty ones. +const zeroRect = () => + ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}), + }) as DOMRect; +for (const proto of [Element.prototype, Range.prototype]) { + if (!proto.getClientRects) { + proto.getClientRects = () => [] as unknown as DOMRectList; + } + if (!proto.getBoundingClientRect) { + proto.getBoundingClientRect = zeroRect; + } +} + +async function renderComposer(props: Parameters[0] = {}) { + const utils = render(); + // useEditor with immediatelyRender:false mounts the ProseMirror view in an + // effect, so the DOM arrives a tick after render. + const dom = await waitFor(() => { + const el = utils.container.querySelector(".ProseMirror"); + if (!el) throw new Error("editor not mounted yet"); + return el; + }); + return { ...utils, dom }; +} + +/** Dispatch a keydown on the editor DOM; returns the event for its flags. */ +function keydown( + dom: HTMLElement, + key: string, + mods: Partial<{ + shift: boolean; + meta: boolean; + ctrl: boolean; + alt: boolean; + }> = {}, +): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key, + shiftKey: mods.shift ?? false, + metaKey: mods.meta ?? false, + ctrlKey: mods.ctrl ?? false, + altKey: mods.alt ?? false, + bubbles: true, + cancelable: true, + }); + act(() => { + dom.dispatchEvent(event); + }); + return event; +} + +// The pill's accessible name is its label text; the full selection rides +// its title, which is the stable handle across "Plan" / "Plan · pending". +const modePill = () => + screen.getByTitle(/^Permission mode:/) as HTMLButtonElement; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("⇧Tab cycles the permission mode from the editor", () => { + it("advances Manual → Plan and claims the key so focus stays put", async () => { + const onModeChange = vi.fn(); + const { dom } = await renderComposer({ mode: "default", onModeChange }); + const event = keydown(dom, "Tab", { shift: true }); + expect(onModeChange).toHaveBeenCalledWith("plan"); + expect(event.defaultPrevented).toBe(true); + }); + + it("continues the cycle from the shown mode: Plan → Accept edits → Manual", async () => { + const onModeChange = vi.fn(); + const { dom, rerender } = await renderComposer({ + mode: "plan", + onModeChange, + }); + keydown(dom, "Tab", { shift: true }); + expect(onModeChange).toHaveBeenLastCalledWith("acceptEdits"); + rerender(); + keydown(dom, "Tab", { shift: true }); + expect(onModeChange).toHaveBeenLastCalledWith("default"); + }); + + it("cycles onward from a HELD switch, not the confirmed mode", async () => { + const onModeChange = vi.fn(); + const { dom } = await renderComposer({ + mode: "default", + pendingMode: "plan", + onModeChange, + isStreaming: true, + modeSwitchDeferred: true, + }); + keydown(dom, "Tab", { shift: true }); + expect(onModeChange).toHaveBeenCalledWith("acceptEdits"); + }); + + it("leaves a bare Tab, ⌘⇧Tab and ⌥⇧Tab to the browser", async () => { + const onModeChange = vi.fn(); + const { dom } = await renderComposer({ mode: "default", onModeChange }); + expect(keydown(dom, "Tab").defaultPrevented).toBe(false); + expect( + keydown(dom, "Tab", { shift: true, meta: true }).defaultPrevented, + ).toBe(false); + expect( + keydown(dom, "Tab", { shift: true, ctrl: true }).defaultPrevented, + ).toBe(false); + expect( + keydown(dom, "Tab", { shift: true, alt: true }).defaultPrevented, + ).toBe(false); + expect(onModeChange).not.toHaveBeenCalled(); + }); + + it("is inert where the pill is disabled: streaming without deferral, or a disabled composer", async () => { + const onModeChange = vi.fn(); + const streaming = await renderComposer({ + mode: "default", + onModeChange, + isStreaming: true, + }); + expect(modePill()).toBeDisabled(); + expect( + keydown(streaming.dom, "Tab", { shift: true }).defaultPrevented, + ).toBe(false); + expect(onModeChange).not.toHaveBeenCalled(); + streaming.unmount(); + + const disabled = await renderComposer({ + mode: "default", + onModeChange, + disabled: true, + }); + expect(keydown(disabled.dom, "Tab", { shift: true }).defaultPrevented).toBe( + false, + ); + expect(onModeChange).not.toHaveBeenCalled(); + }); + + it("is inert on a surface with no Mode selector", async () => { + const { dom } = await renderComposer({}); + expect(keydown(dom, "Tab", { shift: true }).defaultPrevented).toBe(false); + }); +}); + +describe("the Mode pill under the deferred-switch contract", () => { + it("stays enabled while streaming and reads the held target as pending, with the mode's colour", async () => { + await renderComposer({ + mode: "default", + pendingMode: "plan", + onModeChange: vi.fn(), + isStreaming: true, + modeSwitchDeferred: true, + }); + const pill = modePill(); + expect(pill).toBeEnabled(); + expect(pill).toHaveTextContent("Plan · pending"); + expect(pill.title).toContain("Permission mode: Plan (pending"); + expect(pill.className).toContain("text-info"); + }); + + it("reads the confirmed mode plainly once nothing is held", async () => { + await renderComposer({ mode: "acceptEdits", onModeChange: vi.fn() }); + const pill = modePill(); + expect(pill).toHaveTextContent("Accept edits"); + expect(pill).not.toHaveTextContent("pending"); + expect(pill.className).toContain("text-success"); + }); + + it("gives Manual no colour cue at all", async () => { + await renderComposer({ mode: "default", onModeChange: vi.fn() }); + const pill = modePill(); + expect(pill).toHaveTextContent("Manual"); + expect(pill.className).not.toContain("text-info"); + expect(pill.className).not.toContain("text-success"); + }); + + it("tints the composer box for Plan / Accept edits and not for Manual", async () => { + const plan = await renderComposer({ mode: "plan", onModeChange: vi.fn() }); + expect( + plan.container.querySelector('[class*="border-info/50"]'), + ).not.toBeNull(); + plan.unmount(); + + const manual = await renderComposer({ + mode: "default", + onModeChange: vi.fn(), + }); + expect(manual.container.querySelector('[class*="border-info/50"]')).toBe( + null, + ); + expect( + manual.container.querySelector('[class*="border-success/50"]'), + ).toBeNull(); + }); +}); + +describe("ModeSelector", () => { + it("labels a pending switch and keeps the full selection on the title", () => { + render(); + const pill = modePill(); + expect(pill).toHaveTextContent("Accept edits · pending"); + expect(pill.title).toBe( + "Permission mode: Accept edits (pending — applies when the run ends) — ⇧Tab cycles", + ); + expect(pill.className).toContain("text-success"); + }); +}); diff --git a/studio/src/app/workspace/_components/chat-input-mode-dot.test.tsx b/studio/src/app/workspace/_components/chat-input-mode-dot.test.tsx new file mode 100644 index 0000000000..38c903d56d --- /dev/null +++ b/studio/src/app/workspace/_components/chat-input-mode-dot.test.tsx @@ -0,0 +1,111 @@ +import { render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatInput, ModeSelector } from "./chat-input"; + +/** + * The composer's mode-coloured rail and its row policy, proved through the + * rendered components (the pure tables live in composer-frame.test.ts and + * lib/permission-mode.test.ts): + * + * - The Mode pill carries a filled dot in the box tint's hue for Plan / + * Accept edits and none for Manual, so the pill and the rail agree even + * where the pill's label collapses to "Mode". + * - The editor's wrapper carries `--composer-min-rows` only when a caller + * sets `rows` or `compact`; otherwise the stylesheet's three-row default + * stands alone. + */ + +// jsdom lays nothing out: ProseMirror's scroll-into-view after a focus asks +// the selection for its rects, so give it empty ones. +const zeroRect = () => + ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}), + }) as DOMRect; +for (const proto of [Element.prototype, Range.prototype]) { + if (!proto.getClientRects) { + proto.getClientRects = () => [] as unknown as DOMRectList; + } + if (!proto.getBoundingClientRect) { + proto.getBoundingClientRect = zeroRect; + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("the Mode pill's dot", () => { + it("is filled in the info hue for Plan", () => { + const { getByRole, getByTestId } = render( + {}} />, + ); + const dot = getByTestId("mode-dot"); + expect(dot.className).toContain("rounded-full"); + expect(dot.className).toContain("bg-info"); + expect(dot.getAttribute("aria-hidden")).toBe("true"); + // The pill stays a labelled control; the dot is decoration only. + expect(getByRole("button").textContent).toContain("Plan"); + }); + + it("is filled in the success hue for Accept edits", () => { + const { getByTestId } = render( + {}} />, + ); + expect(getByTestId("mode-dot").className).toContain("bg-success"); + }); + + it("is absent for Manual (no colour means ask-me-first)", () => { + const { queryByTestId } = render( + {}} />, + ); + expect(queryByTestId("mode-dot")).toBeNull(); + }); +}); + +async function editorWrapper(props: Parameters[0] = {}) { + const utils = render(); + // useEditor with immediatelyRender:false mounts the ProseMirror view in an + // effect, so the DOM arrives a tick after render. + await waitFor(() => { + if (!utils.container.querySelector(".ProseMirror")) { + throw new Error("editor not mounted yet"); + } + }); + const wrapper = + utils.container.querySelector(".composer-editor"); + if (!wrapper) throw new Error("no .composer-editor wrapper"); + return { ...utils, wrapper }; +} + +describe("the composer's resting rows", () => { + it("leaves the stylesheet's three-row default alone when nothing is asked", async () => { + const { wrapper } = await editorWrapper(); + expect(wrapper.style.getPropertyValue("--composer-min-rows")).toBe(""); + }); + + it("carries an explicit rows through the custom property the stylesheet reads", async () => { + const { wrapper } = await editorWrapper({ rows: 5 }); + expect(wrapper.style.getPropertyValue("--composer-min-rows")).toBe("5"); + }); + + it("pins a compact (side-panel) composer to one row", async () => { + const { wrapper } = await editorWrapper({ compact: true }); + expect(wrapper.style.getPropertyValue("--composer-min-rows")).toBe("1"); + }); + + it("tints the box for Plan through the shared frame table", async () => { + const { container } = await editorWrapper({ + mode: "plan", + onModeChange: () => {}, + }); + expect(container.querySelector('[class*="border-info/50"]')).not.toBeNull(); + }); +}); diff --git a/studio/src/app/workspace/_components/chat-input-paste.test.tsx b/studio/src/app/workspace/_components/chat-input-paste.test.tsx new file mode 100644 index 0000000000..275d82d9e3 --- /dev/null +++ b/studio/src/app/workspace/_components/chat-input-paste.test.tsx @@ -0,0 +1,177 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatInput } from "./chat-input"; +import { PASTE_CHAR_THRESHOLD, PASTE_LINE_THRESHOLD } from "./composer-paste"; + +/** + * The composer's paste listener, driven through the rendered ChatInput: a + * `paste` event on the ProseMirror DOM with a fake DataTransfer (jsdom has no + * ClipboardEvent), then the visible outcome — an attachment pill, a + * `[Pasted text #N]` chip that Enter expands into onSend, or the editor's own + * literal insert for a small paste. The classification table itself is unit + * tested in composer-paste.test.ts; this file proves the wiring. + */ + +// jsdom lays nothing out: ProseMirror's scroll-into-view after a focus or a +// send asks the selection for its rects, so give it empty ones. +const zeroRect = () => + ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}), + }) as DOMRect; +for (const proto of [Element.prototype, Range.prototype]) { + if (!proto.getClientRects) { + proto.getClientRects = () => [] as unknown as DOMRectList; + } + if (!proto.getBoundingClientRect) { + proto.getBoundingClientRect = zeroRect; + } +} + +const png = (name = "shot.png") => + new File([new Uint8Array([137, 80, 78, 71])], name, { type: "image/png" }); + +const lines = (n: number) => + Array.from({ length: n }, (_, i) => `line ${i + 1}`).join("\n"); + +/** A paste event carrying `files` and a `text/plain` flavour. */ +function pasteEvent(files: File[], text: string): Event { + const event = new Event("paste", { bubbles: true, cancelable: true }); + const data = { + files, + items: [], + types: text ? ["text/plain"] : [], + getData: (format: string) => (format === "text/plain" ? text : ""), + }; + Object.defineProperty(event, "clipboardData", { value: data }); + return event; +} + +async function renderComposer(props: Parameters[0] = {}) { + const utils = render(); + // useEditor with immediatelyRender:false mounts the ProseMirror view in an + // effect, so the DOM arrives a tick after render. + const dom = await waitFor(() => { + const el = utils.container.querySelector(".ProseMirror"); + if (!el) throw new Error("editor not mounted yet"); + return el; + }); + return { ...utils, dom }; +} + +const paste = (dom: HTMLElement, files: File[], text: string) => { + let event: Event | undefined; + act(() => { + event = pasteEvent(files, text); + dom.dispatchEvent(event); + }); + if (!event) throw new Error("paste not dispatched"); + return event; +}; + +const pressEnter = (dom: HTMLElement) => { + act(() => { + dom.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + }); +}; + +describe("ChatInput paste", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("stages a pasted clipboard image as an attachment and sends it with the text", async () => { + const onSend = vi.fn(); + const { dom } = await renderComposer({ onSend }); + const file = png(); + const event = paste(dom, [file], ""); + // Handled here: the browser's default (and ProseMirror's paste) must not run. + expect(event.defaultPrevented).toBe(true); + expect(await screen.findByText("shot.png")).toBeInTheDocument(); + // The staged file rides the send like a picked or dropped one. + paste(dom, [], "see the screenshot"); + await waitFor(() => + expect(dom.textContent).toContain("see the screenshot"), + ); + pressEnter(dom); + expect(onSend).toHaveBeenCalledWith("see the screenshot", [file]); + }); + + it("refuses a pasted image through the same staging gate as the picker", async () => { + const { dom } = await renderComposer({ + mediaCapabilities: { image: false, audio: false }, + }); + paste(dom, [png("nope.png")], ""); + expect( + await screen.findByText( + "nope.png: this model does not accept image input.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText("nope.png")).toBeNull(); + }); + + it("stages a large text paste as a [Pasted text #N] chip that expands on send", async () => { + const onSend = vi.fn(); + const { dom } = await renderComposer({ onSend }); + const payload = lines(PASTE_LINE_THRESHOLD); + const event = paste(dom, [], payload); + expect(event.defaultPrevented).toBe(true); + await waitFor(() => expect(dom.textContent).toBe("[Pasted text #1]")); + // The payload is not in the DOM… + expect(dom.textContent).not.toContain("line 2"); + // …but Enter sends the full text. + pressEnter(dom); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend.mock.calls[0][0]).toBe(payload); + await waitFor(() => expect(dom.textContent).toBe("")); + }); + + it("numbers chips within a draft and restarts after a send", async () => { + const onSend = vi.fn(); + const { dom } = await renderComposer({ onSend }); + paste(dom, [], "x".repeat(PASTE_CHAR_THRESHOLD)); + paste(dom, [], "y".repeat(PASTE_CHAR_THRESHOLD)); + await waitFor(() => + expect(dom.textContent).toBe("[Pasted text #1][Pasted text #2]"), + ); + pressEnter(dom); + expect(onSend.mock.calls[0][0]).toBe( + "x".repeat(PASTE_CHAR_THRESHOLD) + "y".repeat(PASTE_CHAR_THRESHOLD), + ); + await waitFor(() => expect(dom.textContent).toBe("")); + paste(dom, [], "z".repeat(PASTE_CHAR_THRESHOLD)); + await waitFor(() => expect(dom.textContent).toBe("[Pasted text #1]")); + }); + + it("leaves a small text paste to the editor (literal insert)", async () => { + const onSend = vi.fn(); + const { dom } = await renderComposer({ onSend }); + paste(dom, [], "hello there"); + // Not intercepted: ProseMirror's own handler inserted the text literally + // (it is the one that prevented the browser default), and no chip exists. + await waitFor(() => expect(dom.textContent).toBe("hello there")); + expect(dom.querySelector("[data-paste-placeholder]")).toBeNull(); + pressEnter(dom); + expect(onSend).toHaveBeenCalledWith("hello there", undefined); + }); + + it("is inert while the composer is disabled", async () => { + const { dom } = await renderComposer({ disabled: true }); + const event = paste(dom, [png()], ""); + expect(event.defaultPrevented).toBe(false); + expect(screen.queryByText("shot.png")).toBeNull(); + }); +}); diff --git a/studio/src/app/workspace/_components/chat-input-queue-keys.test.ts b/studio/src/app/workspace/_components/chat-input-queue-keys.test.ts new file mode 100644 index 0000000000..3651e1f090 --- /dev/null +++ b/studio/src/app/workspace/_components/chat-input-queue-keys.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { resolveEmptyComposerKey } from "./chat-input"; + +/** + * The empty-composer queue gestures, tested through the pure decision table + * the composer's capture-phase keydown handler calls (driving the TipTap + * editor in jsdom is impractical — see chat-input.test.tsx). The handler + * only consults the table once it has established the composer is empty. + */ +describe("resolveEmptyComposerKey", () => { + const resolve = ( + key: string, + overrides: Partial<{ + queuedCount: number; + isStreaming: boolean; + menuOpen: boolean; + }> = {}, + ) => + resolveEmptyComposerKey({ + key, + queuedCount: 2, + isStreaming: false, + menuOpen: false, + ...overrides, + }); + + it("Enter on an idle composer with a held queue resumes it", () => { + expect(resolve("Enter")).toBe("resume"); + }); + + it("Enter while streaming does nothing to the queue (Enter keeps its queue/steer meaning)", () => { + expect(resolve("Enter", { isStreaming: true })).toBeNull(); + }); + + it("ArrowUp pulls the queue back for editing whether or not a run is live", () => { + expect(resolve("ArrowUp")).toBe("edit"); + expect(resolve("ArrowUp", { isStreaming: true })).toBe("edit"); + }); + + it("Escape on an idle composer clears the held queue", () => { + expect(resolve("Escape")).toBe("clear"); + }); + + it("Escape while streaming is left to the run-cancel shortcut", () => { + expect(resolve("Escape", { isStreaming: true })).toBeNull(); + }); + + it("does nothing with an open autocomplete menu or an empty queue", () => { + expect(resolve("Enter", { menuOpen: true })).toBeNull(); + expect(resolve("ArrowUp", { menuOpen: true })).toBeNull(); + expect(resolve("Escape", { menuOpen: true })).toBeNull(); + expect(resolve("Enter", { queuedCount: 0 })).toBeNull(); + expect(resolve("ArrowUp", { queuedCount: 0 })).toBeNull(); + expect(resolve("Escape", { queuedCount: 0 })).toBeNull(); + }); + + it("ignores every other key", () => { + expect(resolve("ArrowDown")).toBeNull(); + expect(resolve("a")).toBeNull(); + expect(resolve("Tab")).toBeNull(); + }); +}); diff --git a/studio/src/app/workspace/_components/chat-input.test.tsx b/studio/src/app/workspace/_components/chat-input.test.tsx new file mode 100644 index 0000000000..c515f083bb --- /dev/null +++ b/studio/src/app/workspace/_components/chat-input.test.tsx @@ -0,0 +1,614 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AttachmentPill, + agentMenuItems, + commandMenuItems, + commandNoMatchHint, + commandSourceTag, + ModelEffortSelector, + resolveComposerAction, + resolveComposerSubmission, +} from "./chat-input"; +import { NO_REASONING_WARNING } from "./effort-picker"; + +// The composer's `/` menu reads the daemon's command list through this +// module-level getter; the local-command tests swap it for a fixed roster +// and keep every other export (the Studio builtins) real. +const daemon = vi.hoisted(() => ({ + commands: [] as { name: string; description: string }[], + agents: [] as { handle: string; name: string; description: string }[], +})); +vi.mock("@/features/agent/composer-capabilities", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/features/agent/composer-capabilities") + >()), + getSlashCommands: () => daemon.commands, + getAgentMentions: () => daemon.agents, +})); + +/** + * Studio's built-ins (`/clear /help /session /retry /diagnostics /compact`) + * are intercepted on send: a bare one runs locally instead of reaching the + * agent, a held one (arguments, a second line) or a gated-off one stays in + * the editor with a warning, and the `/` menu lists them ahead of the + * daemon's commands. Both halves are pure and tested here — interception + * through `resolveComposerSubmission`, the helper performAction runs BEFORE + * its steer/queue/send branches, so a built-in typed mid-stream is + * intercepted identically (never queued or steered). + */ +describe("resolveComposerSubmission", () => { + const OPEN = { manualCompaction: true }; + + it("runs a bare built-in, case-insensitively, with trailing whitespace", () => { + for (const text of ["/help", "/HELP ", "/Help\n", "/clear"]) { + expect( + resolveComposerSubmission({ text, gates: OPEN, hasHandler: true }), + ).toEqual({ + action: "builtin", + name: text.trim().toLowerCase().slice(1), + }); + } + }); + + it("holds a built-in with arguments, even while streaming", () => { + expect( + resolveComposerSubmission({ + text: "/clear now", + gates: OPEN, + hasHandler: true, + isStreaming: true, + }), + ).toEqual({ + action: "hold", + warning: "/clear takes no arguments — remove the text to run it", + }); + }); + + it("holds a gated-off built-in with the daemon warning", () => { + expect( + resolveComposerSubmission({ + text: "/compact", + gates: { manualCompaction: false }, + hasHandler: true, + }), + ).toEqual({ + action: "hold", + warning: "/compact is not available on this daemon", + }); + // No gates at all fails closed the same way. + expect( + resolveComposerSubmission({ text: "/compact", hasHandler: true }), + ).toMatchObject({ action: "hold" }); + }); + + it("passes everything else to the agent", () => { + for (const text of ["/helpme", "help", "", "/review", "/deploy prod"]) { + expect( + resolveComposerSubmission({ text, gates: OPEN, hasHandler: true }), + ).toEqual({ action: "pass" }); + } + }); + + it("passes even a bare built-in where no handler is wired", () => { + expect( + resolveComposerSubmission({ + text: "/help", + gates: OPEN, + hasHandler: false, + }), + ).toEqual({ action: "pass" }); + }); +}); + +/** + * The `@` menu (TUI file-mention parity): the file rows lead — "Attach a + * file…" while the query could still spell "file", "Mention path" for a + * path-shaped token — ahead of the agent roster; an agent query shows + * neither. The rows are plain menu items (no new mention kind), recognised + * by their sentinel ids in the composer's select handler. + */ +describe("agentMenuItems", () => { + beforeEach(() => { + daemon.agents = [ + { handle: "reviewer", name: "Reviewer", description: "reviews diffs" }, + { handle: "planner", name: "Planner", description: "plans work" }, + ]; + }); + afterEach(() => { + daemon.agents = []; + }); + + it("starts with the Attach-a-file row on an empty query, then every agent", () => { + const items = agentMenuItems(""); + expect(items[0]).toMatchObject({ + id: "__attach-file", + primary: "Attach a file…", + }); + expect(items.slice(1).map((item) => item.id)).toEqual([ + "reviewer", + "planner", + ]); + }); + + it("offers the Mention-path row for a path-shaped query", () => { + const items = agentMenuItems("src/"); + expect(items.map((item) => item.primary)).toEqual(["Mention path @src/"]); + expect(items[0]?.label).toBe("@src/"); + }); + + it("shows neither file row for an agent query, filtering by handle prefix or name", () => { + const items = agentMenuItems("rev"); + expect(items.map((item) => item.id)).toEqual(["reviewer"]); + expect(agentMenuItems("PLAN").map((item) => item.id)).toEqual(["planner"]); + }); +}); + +describe("commandMenuItems", () => { + const STUDIO_HELP = "show keys & features"; + const OPEN = { manualCompaction: true }; + + beforeEach(() => { + daemon.commands = [{ name: "review", description: "Review a diff" }]; + }); + + it("lists the Studio builtins first, in fixed order, then the daemon's commands", () => { + const items = commandMenuItems("", { gates: OPEN }); + // No capability document: only the always-present built-ins, then + // the daemon's list. + expect(items.map((i) => i.id)).toEqual([ + "clear", + "help", + "session", + "retry", + "diagnostics", + "compact", + "title", + "learning", + "review", + ]); + expect(items[1]?.primary).toBe("/help"); + expect(items[1]?.secondary).toBe(STUDIO_HELP); + // Builtin rows carry the mark the palette renders as a terminal glyph + // and the "built-in" source tag; daemon rows read "workspace". + expect(items.slice(0, 8).every((i) => i.builtin === true)).toBe(true); + expect(items[8]?.builtin).toBeUndefined(); + expect(items.slice(0, 8).map(commandSourceTag)).toEqual( + Array(8).fill("built-in"), + ); + expect(commandSourceTag(items[8] as (typeof items)[number])).toBe( + "workspace", + ); + }); + + it("adds the capability-gated built-ins the daemon enables, ahead of the daemon's commands", () => { + const items = commandMenuItems("", { + gates: { + ...OPEN, + capabilities: { mcp: true, agents: true, posture: "auto" }, + }, + }); + const ids = items.map((i) => i.id); + expect(ids).toEqual([ + "clear", + "help", + "session", + "retry", + "diagnostics", + "compact", + "title", + "mcp", + "prompts", + "resources", + "agents", + // /models and /effort ride the picker's gate (absent = shown). + "models", + "effort", + "posture", + "learning", + "review", + ]); + expect(ids).not.toContain("skills"); + expect(items.find((i) => i.id === "mcp")?.secondary).toMatch(/MCP tools/); + }); + + it("names the typed token in the no-match hint, and only for the `/` menu", () => { + expect( + commandNoMatchHint({ kind: "command", items: [], query: "zzz" }), + ).toBe("No command matches /zzz — Enter sends it as text"); + // Rows to pick, an empty query (the bare `/`) or the `@` menu: no hint. + expect( + commandNoMatchHint({ kind: "command", items: [{}], query: "zzz" }), + ).toBeNull(); + expect( + commandNoMatchHint({ kind: "command", items: [], query: "" }), + ).toBeNull(); + expect( + commandNoMatchHint({ kind: "agent", items: [], query: "zzz" }), + ).toBeNull(); + // The hint is what the palette renders when the prefix matches nothing. + expect(commandMenuItems("zzz", { gates: OPEN })).toEqual([]); + }); + + it("hides /compact unless the daemon enables manual compaction", () => { + expect( + commandMenuItems("", { gates: { manualCompaction: false } }).map( + (i) => i.id, + ), + ).not.toContain("compact"); + // No gates at all fails closed. + expect(commandMenuItems("").map((i) => i.id)).not.toContain("compact"); + }); + + it("filters both layers by prefix", () => { + expect(commandMenuItems("he", { gates: OPEN }).map((i) => i.id)).toEqual([ + "help", + ]); + expect(commandMenuItems("re", { gates: OPEN }).map((i) => i.id)).toEqual([ + "retry", + "review", + ]); + }); + + it("shadows a daemon command named like a builtin — the Studio row wins", () => { + daemon.commands = [ + { name: "help", description: "daemon help" }, + { name: "review", description: "Review a diff" }, + ]; + const items = commandMenuItems("", { gates: OPEN }); + expect(items.filter((i) => i.id === "help")).toHaveLength(1); + expect(items.find((i) => i.id === "help")?.secondary).toBe(STUDIO_HELP); + expect(items.at(-1)?.id).toBe("review"); + }); + + it("omits the builtins for a surface with no local-command handler", () => { + daemon.commands = [{ name: "help", description: "daemon help" }]; + const items = commandMenuItems("", { builtins: false, gates: OPEN }); + expect(items.map((i) => i.id)).toEqual(["help"]); + expect(items[0]?.secondary).toBe("daemon help"); + expect(items[0]?.builtin).toBeUndefined(); + }); +}); + +/** + * The Enter matrix is tested through `resolveComposerAction`, the pure + * decision function the composer's keydown handler and send button both call. + * Driving the TipTap editor's ProseMirror view with synthetic keydowns in + * jsdom is impractical (the editor mounts asynchronously and owns its own + * capture-phase handlers), so the decision table is extracted and tested + * exhaustively instead; "newline" means the key is NOT intercepted — the + * editor's own hardBreak inserts the newline and onSend is never called. + */ +describe("resolveComposerAction", () => { + const resolve = ( + shift: boolean, + isStreaming: boolean, + behavior: "queue" | "steer", + ) => resolveComposerAction({ shift, isStreaming, behavior }); + + it("sends on idle Enter, whatever the preference", () => { + expect(resolve(false, false, "queue")).toBe("send"); + expect(resolve(false, false, "steer")).toBe("send"); + }); + + it("keeps idle Shift+Enter as a newline (the key is not intercepted, so onSend is never called)", () => { + expect(resolve(true, false, "queue")).toBe("newline"); + expect(resolve(true, false, "steer")).toBe("newline"); + }); + + it("queues on streaming Enter with the default preference", () => { + expect(resolve(false, true, "queue")).toBe("queue"); + }); + + it("steers on streaming Shift+Enter with the default preference", () => { + expect(resolve(true, true, "queue")).toBe("steer"); + }); + + it("inverts both keys when the preference is steer", () => { + expect(resolve(false, true, "steer")).toBe("steer"); + expect(resolve(true, true, "steer")).toBe("queue"); + }); + + // ⌘Enter / Ctrl+Enter (the TUI's ctrl+j): the unconditional newline. It + // wins over every other rule — idle or streaming, either preference, shift + // held or not — so a multi-line message can be composed mid-run, where + // Shift+Enter is repurposed. The keydown handler leaves the chord to the + // editor's Mod-Enter hardBreak, so "newline" here means never intercepted. + it("keeps mod+Enter a newline in every cell of the matrix", () => { + for (const isStreaming of [false, true]) { + for (const behavior of ["queue", "steer"] as const) { + for (const shift of [false, true]) { + expect( + resolveComposerAction({ shift, mod: true, isStreaming, behavior }), + ).toBe("newline"); + } + } + } + // An absent `mod` is the legacy call: the rest of the table is untouched. + expect( + resolveComposerAction({ + shift: false, + isStreaming: true, + behavior: "queue", + }), + ).toBe("queue"); + }); + + // Attachments no longer force the queue path: a steer carries staged image + // parts (ADR 0251). Availability is the handler's business — performAction + // degrades steer→queue when onSteer is absent, keeping the files attached. + + // "Queue only" is the client-level never-steer switch (mecatui --no-steer): + // both keys queue while streaming — Shift+Enter has no opposite to invert + // into — and the idle cells are the ordinary send / newline. + it("queues on BOTH streaming Enter and Shift+Enter under queue-only", () => { + expect( + resolveComposerAction({ + shift: false, + isStreaming: true, + behavior: "queue-only", + }), + ).toBe("queue"); + expect( + resolveComposerAction({ + shift: true, + isStreaming: true, + behavior: "queue-only", + }), + ).toBe("queue"); + }); + + it("leaves the idle and mod+Enter cells alone under queue-only", () => { + expect( + resolveComposerAction({ + shift: false, + isStreaming: false, + behavior: "queue-only", + }), + ).toBe("send"); + expect( + resolveComposerAction({ + shift: true, + isStreaming: false, + behavior: "queue-only", + }), + ).toBe("newline"); + expect( + resolveComposerAction({ + shift: false, + mod: true, + isStreaming: true, + behavior: "queue-only", + }), + ).toBe("newline"); + }); +}); + +describe("AttachmentPill", () => { + // jsdom has no object-URL implementation; stub the pair the pill uses. + const createObjectURL = vi.fn(() => "blob:thumb"); + const revokeObjectURL = vi.fn(); + + beforeEach(() => { + createObjectURL.mockClear(); + revokeObjectURL.mockClear(); + vi.stubGlobal("URL", { + ...URL, + createObjectURL, + revokeObjectURL, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows a thumbnail for an image file and revokes its object URL on unmount", async () => { + const file = new File(["x"], "photo.png", { type: "image/png" }); + const { container, unmount } = render( + {}} />, + ); + await waitFor(() => + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "blob:thumb", + ), + ); + unmount(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:thumb"); + }); + + it("shows a file-kind glyph, not a thumbnail, for a non-image file", () => { + const file = new File(["x"], "report.pdf", { type: "application/pdf" }); + const { container } = render( + {}} />, + ); + expect(container.querySelector("img")).toBeNull(); + expect(createObjectURL).not.toHaveBeenCalled(); + expect(container.querySelector('[aria-label="PDF"]')).toBeTruthy(); + expect(container.textContent).toContain("report.pdf"); + }); +}); + +/** + * The combined model + effort picker: the Effort submenu is real in BOTH + * modes — a draft's pick is reported for the create body, a live chat's pick + * forks (like a model switch) and the checkmark follows the daemon's + * EFFECTIVE tier — and it hides with the model list when the daemon's + * model_selection capability is off. + */ +describe("ModelEffortSelector", () => { + const models = [ + { id: "m1", label: "Model One", providerId: "p", reasoning: false }, + { id: "m2", label: "Model Two", providerId: "p", reasoning: true }, + ]; + + /** The trigger is the only button before the menu opens. */ + const trigger = () => screen.getByRole("button"); + + /** Opens the root menu (a real pointer on the trigger), then the Effort + * submenu. The sub is opened and its rows picked with plain clicks: jsdom + * reports zero-size rects, so a simulated pointer MOVE onto a sub row + * reads to Radix as leaving the submenu and closes it. */ + async function openEffort(user: ReturnType) { + await user.click(trigger()); + fireEvent.click(await screen.findByRole("menuitem", { name: /^Effort/ })); + return screen.findAllByRole("menuitemradio"); + } + + const pick = (name: string) => + fireEvent.click(screen.getByRole("menuitemradio", { name })); + + it("live chat: labels the trigger {model} · {effort} from the resolved tier and offers the Effort submenu", async () => { + const user = userEvent.setup(); + const onSwitchEffort = vi.fn(); + render( + {}} + onSwitchEffort={onSwitchEffort} + currentModelId="m2" + currentEffort="medium" + />, + ); + expect(trigger()).toHaveAttribute("title", "Model Two · Medium"); + const rows = await openEffort(user); + expect(rows.map((r) => r.textContent)).toEqual([ + "Auto", + "Low", + "Medium", + "High", + "Extra high", + "Max", + ]); + expect( + screen.getByRole("menuitemradio", { name: "Medium" }), + ).toHaveAttribute("aria-checked", "true"); + // A reasoning model: no warning. + expect(screen.queryByRole("note")).toBeNull(); + // Picking a different tier forks with the WIRE value. + pick("High"); + expect(onSwitchEffort).toHaveBeenCalledWith("high"); + }); + + it("live chat: re-picking the current tier is a no-op, auto forks with the empty value", async () => { + const user = userEvent.setup(); + const onSwitchEffort = vi.fn(); + render( + {}} + onSwitchEffort={onSwitchEffort} + currentModelId="m2" + currentEffort="medium" + />, + ); + await openEffort(user); + pick("Medium"); + expect(onSwitchEffort).not.toHaveBeenCalled(); + // Radix closes the menu on select, so the trigger is reachable again. + await openEffort(user); + pick("Auto"); + expect(onSwitchEffort).toHaveBeenCalledWith(""); + }); + + it("live chat: warns when the session's model reports no reasoning support", async () => { + const user = userEvent.setup(); + render( + {}} + onSwitchEffort={() => {}} + currentModelId="m1" + currentEffort="" + />, + ); + // No tier echoed = auto. + expect(trigger()).toHaveAttribute("title", "Model One · Auto"); + await openEffort(user); + expect(screen.getByRole("note")).toHaveTextContent(NO_REASONING_WARNING); + }); + + it("live chat: the caller's reasoning flag wins over the option lookup", async () => { + const user = userEvent.setup(); + render( + {}} + onSwitchEffort={() => {}} + currentModelId="m2" + currentEffort="low" + currentModelReasoning={false} + />, + ); + await openEffort(user); + expect(screen.getByRole("note")).toHaveTextContent(NO_REASONING_WARNING); + }); + + it("draft: reports the pending pick as a wire value, relabels the trigger, and reset returns to untouched (null)", async () => { + const user = userEvent.setup(); + const onEffortChange = vi.fn(); + const onModelChange = vi.fn(); + render( + , + ); + expect(trigger()).toHaveAttribute("title", "Default · Auto"); + await openEffort(user); + pick("Extra high"); + expect(onEffortChange).toHaveBeenCalledWith("xhigh"); + // Radix closes the menu on select; the trigger reflects the pick. + await waitFor(() => + expect(trigger()).toHaveAttribute("title", "Default · Extra high"), + ); + await user.click(trigger()); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Reset to default" }), + ); + expect(onEffortChange).toHaveBeenLastCalledWith(""); + // Tri-state draft pick: Reset reports null (untouched), not "" (explicit auto). + expect(onModelChange).toHaveBeenCalledWith(null); + await waitFor(() => + expect(trigger()).toHaveAttribute("title", "Default · Auto"), + ); + }); + + it("draft: a controlled effort value drives the checkmark and the label", async () => { + const user = userEvent.setup(); + render( + {}} + onModelChange={() => {}} + />, + ); + expect(trigger()).toHaveAttribute("title", "Default · Max"); + await openEffort(user); + expect(screen.getByRole("menuitemradio", { name: "Max" })).toHaveAttribute( + "aria-checked", + "true", + ); + }); + + it("hides the Effort submenu, and the effort label, when model selection is off", async () => { + const user = userEvent.setup(); + render( + {}} + onSwitchEffort={() => {}} + currentModelId="m2" + currentEffort="medium" + effortSupported={false} + />, + ); + expect(trigger()).toHaveAttribute("title", "Model Two"); + await user.click(trigger()); + await screen.findByRole("menuitem", { name: /^Model/ }); + expect(screen.queryByRole("menuitem", { name: /^Effort/ })).toBeNull(); + }); +}); diff --git a/studio/src/app/workspace/_components/chat-input.tsx b/studio/src/app/workspace/_components/chat-input.tsx new file mode 100644 index 0000000000..fd467ae1a5 --- /dev/null +++ b/studio/src/app/workspace/_components/chat-input.tsx @@ -0,0 +1,2650 @@ +"use client"; + +import Placeholder from "@tiptap/extension-placeholder"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import type { SuggestionProps } from "@tiptap/suggestion"; +import { + ArrowUp, + Bot, + Check, + ChevronDown, + ChevronRight, + FolderClosed, + FolderPlus, + Mic, + Paperclip, + Plug, + Plus, + RotateCcw, + Shield, + SlidersHorizontal, + Terminal, + TriangleAlert, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { + type BuiltinGates, + type BuiltinOutcome, + builtinSlashCommands, + CLOSED_BUILTIN_GATES, + classifySlashLine, + isStudioBuiltinCommand, + type StudioBuiltinCommand, +} from "@/features/agent/composer-builtins"; +import { + getAgentMentions, + getSlashCommands, +} from "@/features/agent/composer-capabilities"; +import { useOptionalRuntimeStatus } from "@/features/agent/runtime-status"; +import { useConfirm } from "@/hooks/use-confirm"; +import { usePrompt } from "@/hooks/use-prompt"; +import { + classifyAttachment, + type MediaCapabilities, +} from "@/lib/attachment-inline"; +import { ALLOW_ALL_POSTURES } from "@/lib/controller-permissions.mjs"; +import { fileKindMeta } from "@/lib/file-meta"; +import { saveHarnessPermissions } from "@/lib/harness/client"; +import { useDefaultModel } from "@/lib/model-preferences"; +import { + modeAccentClass, + modeDotClass, + nextPermissionMode, + PERMISSION_MODE_OPTIONS, + permissionModeLabel, + resolveModeCycleKey, +} from "@/lib/permission-mode"; +import { + type EnterSendBehavior, + useEnterSendBehavior, +} from "@/lib/profile-preferences"; +import type { SessionPermissionMode } from "@/lib/protocol"; +import { + type SessionToolProfile, + toolProfilePillSuffix, +} from "@/lib/tool-profile"; +import { cn } from "@/lib/utils"; +import { ClearDraftButton } from "./clear-draft-button"; +import { + ESCAPE_ARMED_HINT, + useComposerDraftGuards, +} from "./composer-draft-guards"; +import { + fileMenuRows, + isAttachFileItem, + isFileMenuItem, + pathMentionText, +} from "./composer-file-mention"; +import { composerEditorStyle, composerFrameClass } from "./composer-frame"; +import { isAlwaysNewlineChord, isClearDraftChord } from "./composer-keys"; +import { + type ComposerMenuItem, + composerText, + createComposerMentions, + setComposerText, +} from "./composer-mentions"; +import { + applyComposerPaste, + PastePlaceholder, + readPasteClipboard, +} from "./composer-paste"; +import { liveModelProvenance, resolveDraftModel } from "./draft-model"; +import { + EffortSheetSection, + EffortSubmenu, + effortTriggerLabel, + ModelPickerShortcut, +} from "./effort-picker"; +import { + McpInsertMenu, + type McpPickerKind, + useMcpComposerInsert, +} from "./mcp-composer-insert"; +import { ModelSheetSection, ModelSubmenuContent } from "./model-picker"; +import { ModelPickerOpener } from "./model-picker-opener"; +import { + ToolProfileSelector, + ToolProfileSheetRows, +} from "./tool-profile-picker"; + +interface ProjectItem { + id: string; + name: string; + color: string; +} + +interface ChatInputProps { + placeholder?: string; + rows?: number; + projects?: ProjectItem[]; + selectedProjectId?: string | null; + onSelectProject?: (id: string | null) => void; + onCreateProject?: (name: string) => void; + compact?: boolean; + /** Docked at a screen edge on mobile: full-bleed single row with only a + hairline top border, instead of the floating rounded box. */ + mobileDocked?: boolean; + /** Refocuses the field when this changes (e.g. the open chat's id). */ + focusKey?: string; + onSend?: (content: string, files?: File[]) => void; + /** Holds the text — plus the staged files, which leave the composer with + it — for the next run (mid-run queuing). */ + onQueue?: (content: string, files?: File[]) => void; + /** Injects the text — plus any staged image attachments (ADR 0251) — into + the in-flight run at the next step (mid-run steering). Only meaningful + while `isStreaming`; absent when the daemon lacks the steer capability + (mid-run sends then queue and files stay attached). */ + onSteer?: (content: string, files?: File[]) => void; + /** Draft: the pending model pick ("" = the auto row, i.e. the daemon + * default on purpose); null = reset to untouched, so the Studio default + * for new chats applies again when the daemon lists it. */ + onModelChange?: (id: string | null) => void; + /** Live daemon models for the picker; absent = the sentinel only. */ + models?: ComposerModelOption[]; + /** Label for the empty (daemon-picks) entry. */ + autoModelLabel?: string; + /** Preview an attached file in the canvas panel. */ + onPreviewAttachment?: (file: File) => void; + disabled?: boolean; + isStreaming?: boolean; + /** Keeps the Mode selector enabled while `isStreaming`: the caller defers + the switch until the run ends (the status strip shows it "(pending)"), + instead of disabling the pill mid-run. */ + modeSwitchDeferred?: boolean; + /** A mode switch held until the run ends (the TUI's `mode + pending`): the pill shows it "· pending" with its colour cue, and ⇧Tab + cycles onward from it. `mode` stays the daemon-confirmed value. */ + pendingMode?: SessionPermissionMode | null; + appendText?: string | null; + onAppendConsumed?: () => void; + /** Plain-text seed dropped into an empty composer (e.g. a "next step" chip + that pre-fills a prompt without sending it). Unlike `appendText`, it is + not quoted and replaces rather than appends. */ + initialText?: string | null; + /** Files re-staged alongside `initialText` (a queued message pulled back + for editing brings its attachments with it); consumed together. */ + initialFiles?: File[]; + onInitialTextConsumed?: () => void; + /** Messages held in the queue strip. On an EMPTY composer with a held + queue: Enter (idle) sends them via `onResumeQueue`, ↑ pulls them back + via `onEditAllQueued`, Esc (idle) drops them via `onClearQueue`. */ + queuedCount?: number; + onResumeQueue?: () => void; + onEditAllQueued?: () => void; + onClearQueue?: () => void; + /** Display-only model label for live-harness sessions (see ModelSelector). */ + modelLockedLabel?: string; + /** Live-chat model switch: picking forks the chat onto the model (the + * daemon fixes a session's model at create). null = auto-routed. */ + onSwitchModel?: (option: ComposerModelOption | null) => void; + /** The live session's current model id ("" = auto). */ + currentModelId?: string; + /** Draft: the pending reasoning-effort tier as a WIRE value ("" = auto), + * applied when the first send mints the session. */ + effort?: string; + onEffortChange?: (wire: string) => void; + /** Live-chat effort switch: picking forks the chat onto the tier (the + * daemon fixes a session's effort at create, like its model). */ + onSwitchEffort?: (wire: string) => void; + /** The live session's EFFECTIVE tier (resolved_model.reasoning_effort; + * "" = auto), for the checkmark and the trigger label. */ + currentEffort?: string; + /** The live session's model `reasoning` flag; false shows the Effort + * list's "a tier may be ignored" warning. */ + currentModelReasoning?: boolean; + /** False when the daemon's model_selection capability is off: hides the + * Effort list along with the model list. */ + effortSupported?: boolean; + /** The session's current permission mode, shown by the Mode selector. */ + mode?: SessionPermissionMode; + /** Renders the Mode selector (first in the control bar) when provided. + Surfaces without a mode concept (the thread panel, the mock tour chat) + simply omit it. */ + onModeChange?: (mode: SessionPermissionMode) => void; + /** The chat's TOOL PROFILE (the daemon's per-session `profile`, ADR 0291): + "" = all tools, "no-fs" = the file-less catalog. Shown inside the Mode + menu as a "Tools" section. Undefined = unknown (a live chat Studio did + not mint), which renders no line at all. */ + profile?: SessionToolProfile; + /** Draft only: picks the profile the first send mints with. Absent on a + live chat — the profile is fixed at create — where a known `profile` + renders read-only. */ + onProfileChange?: (profile: SessionToolProfile) => void; + /** Answers a Studio built-in slash command (`/clear /help /session /retry + /diagnostics /compact`) instead of sending it: picked from the `/` menu + or typed as the whole message, this fires; `{ ok: true }` (or no + outcome) clears the composer, a refusal keeps the text and shows its + warning above the input. Absent, the builtins are not offered and the + text goes to the agent like any other message. */ + onLocalCommand?: ( + command: StudioBuiltinCommand, + ) => BuiltinOutcome | undefined; + /** Which gated built-ins the daemon enables (`/compact` needs manual + compaction). Fail-closed when omitted. */ + builtinGates?: BuiltinGates; + /** What the session may send as media (its resolved input modalities). + Gates staging: a file the model cannot take is refused with a reason + instead of being staged. Absent = no media gate (text-size limits still + apply); the send path gates again regardless. */ + mediaCapabilities?: MediaCapabilities; + /** The forwarded double-Esc counter (`useComposerEscape`): each change is + one Esc nothing else claimed; the first arms, a second within 1.5 s + clears the draft. Absent = no Esc-to-clear (the thread panel). */ + escapePress?: number; + /** Fires when "the composer holds text" flips (and `false` on unmount): + the surface arms its Esc layering and the leave guard from it. */ + onDraftChange?: (hasText: boolean) => void; + /** Persists the unsent draft under this identity (a session id, or `"new"` + for the draft view) so a reload never loses it; absent = transient. */ + draftKey?: string; +} + +/** + * Ghost-style trigger used by dropdowns rendered BELOW the input box + * (matches the "Build / Claude Opus 5 / Default" reference design). + * No pill background; subtle hover; small chevron via the trigger itself. + */ +const GHOST_TRIGGER_CLASS = + "h-7 gap-1 rounded-full px-2.5 text-sm font-normal text-foreground bg-transparent hover:bg-zinc-200 dark:hover:bg-zinc-700 border-0 shadow-none"; + +/** The composer's "+" button: opens the file picker directly to attach files. */ +function FilesDropdown({ + onFilesSelected, + inputRef, +}: { + onFilesSelected?: (files: File[]) => void; + /** Lifted so the composer's `@` "Attach a file…" row opens the SAME + native picker; absent, the dropdown owns its own input. */ + inputRef?: React.RefObject; +}) { + const ownInputRef = useRef(null); + const fileInputRef = inputRef ?? ownInputRef; + + return ( + <> + {/* Any file: images and audio become media parts (gated when staged), + everything else is inlined as text — the TUI's attach parity. */} + { + const selected = e.target.files; + if (selected && selected.length > 0) { + onFilesSelected?.(Array.from(selected)); + } + e.target.value = ""; + }} + /> + + + ); +} + +/** + * On mobile the composer must NOT grab focus when a chat opens — the keyboard + * would pop over the transcript the user came to read. Read the breakpoint + * synchronously (module-stable, so focus effects need not depend on it): the + * useIsMobile hook reports undefined for a render, which would race the + * mount-time autofocus. + */ +const mobileViewport = () => + typeof window !== "undefined" && + window.matchMedia("(max-width: 499px)").matches; + +/** The daemon-picks sentinel (model_id omitted at create). Its label is + * supplied by the caller: "Auto-routed" only while the router is really on. */ +const AUTO_MODEL_ID = ""; +const autoModel = (label?: string): ComposerModelOption => ({ + id: AUTO_MODEL_ID, + label: label ?? "Default", +}); + +export interface ComposerModelOption { + id: string; + label: string; + /** The daemon requires provider_id whenever model_id rides a create. */ + providerId?: string; + /** The inventory's `reasoning` flag: false → the Effort list warns that a + * tier may be ignored; absent = unknown (no warning). */ + reasoning?: boolean; + /** The inventory's `image` flag: the picker row shows an image glyph. */ + image?: boolean; + /** Context window in tokens (0/absent = unknown, no label on the row). */ + contextLimit?: number; +} + +/** The reasoning-effort sentinel as a WIRE value: "" = auto (the create/fork + * body omits `reasoning_effort`, so the operator's default applies). The + * tiers themselves live in `@/lib/reasoning-effort` (see ./effort-picker). */ +const DEFAULT_EFFORT = ""; + +/** One tappable choice row inside a mobile picker sheet (MobileChatMenu's + * row idiom plus a trailing checkmark). */ +function SheetOptionRow({ + label, + description, + selected, + onSelect, +}: { + label: string; + description?: string; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +/** Muted section heading inside a mobile picker sheet. */ +function SheetSectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** + * Combined model + effort picker in a single menu: the trigger reads + * "{model} {effort}", and the menu drills into a Model submenu and an Effort + * submenu, with a reset. When `lockedLabel` is set (a live harness routes the + * model server-side) the trigger is display-only. On mobile the menu is a + * bottom sheet instead (the MobileChatMenu convention) with the submenus + * flattened into sections — it stays open across taps so model and effort + * can be set in one visit. + */ +export function ModelEffortSelector({ + onModelChange, + lockedLabel, + onSwitchModel, + currentModelId, + models, + autoModelLabel, + effort, + onEffortChange, + onSwitchEffort, + currentEffort, + currentModelReasoning, + effortSupported = true, +}: { + /** Draft pick ("" = auto row); null = reset to untouched (Studio default). */ + onModelChange?: (id: string | null) => void; + lockedLabel?: string; + /** Live-chat switch: picking forks the chat onto the model. */ + onSwitchModel?: (option: ComposerModelOption | null) => void; + currentModelId?: string; + models?: ComposerModelOption[]; + autoModelLabel?: string; + /** Draft: the pending reasoning-effort WIRE value ("" = auto), controlled + * by the caller; uncontrolled local state when absent. */ + effort?: string; + onEffortChange?: (wire: string) => void; + /** Live-chat switch: picking forks the chat onto the effort tier. */ + onSwitchEffort?: (wire: string) => void; + /** The live session's resolved_model.reasoning_effort ("" = auto). */ + currentEffort?: string; + /** The live session's model `reasoning` flag (false → warning row). */ + currentModelReasoning?: boolean; + /** False when the daemon's model_selection capability is off: the Effort + * submenu is hidden with the model list. */ + effortSupported?: boolean; +}) { + const modelOptions = [autoModel(autoModelLabel), ...(models ?? [])]; + // Draft: null = untouched (the browser-local Studio default for new chats + // applies when the daemon lists it), "" = the auto row picked on purpose. + const [model, setModel] = useState(null); + const { defaultModel: studioDefault } = useDefaultModel(); + const draft = resolveDraftModel(model, studioDefault, modelOptions); + const draftId = draft.id; + const [localEffort, setLocalEffort] = useState(DEFAULT_EFFORT); + const [menuOpen, setMenuOpen] = useState(false); + const switchId = currentModelId ?? AUTO_MODEL_ID; + const selectedModel = onSwitchModel + ? (modelOptions.find((m) => m.id === switchId) ?? { + id: switchId, + label: switchId || autoModel(autoModelLabel).label, + }) + : (modelOptions.find((m) => m.id === draftId) ?? modelOptions[0]); + // The picker header's provenance tag: where the model in force came from. + const provenance = onSwitchModel + ? liveModelProvenance(switchId, studioDefault, modelOptions) + : draft.provenance; + // Draft: the pending pick (caller-controlled when wired). Live: the + // daemon's EFFECTIVE tier — never the local pick. + const draftEffort = effort ?? localEffort; + const selectedEffort = onSwitchModel ? (currentEffort ?? "") : draftEffort; + const showEffort = + effortSupported && (onSwitchModel ? Boolean(onSwitchEffort) : true); + // The `reasoning` flag of the model the tier would apply to: the live + // session's (caller-supplied, else its listed option) or the draft's picked + // option; undefined (auto-routed / unlisted) renders no warning. + const pickedOption = modelOptions.find( + (m) => m.id === (onSwitchModel ? switchId : draftId), + ); + const modelReasoning = onSwitchModel + ? (currentModelReasoning ?? pickedOption?.reasoning) + : pickedOption?.reasoning; + // The TUI's F7 analogue: only the picker wired to a session registers the + // shortcut (the thread panel's composer is not), so the main chat's picker + // always wins. + const shortcutWired = Boolean(onSwitchModel || onEffortChange); + + // The toolbar is a CSS container (@container on the footer row): below + // ~28rem — a narrow side-panel composer, not just mobile viewports — the + // value labels collapse to the static word "Model", with the full selection + // kept on the title attribute; in between, truncation caps a long model id. + if (lockedLabel && !onSwitchModel) { + return ( + + ); + } + + return ( + + {shortcutWired && ( + <> + setMenuOpen(true)} /> + {/* The `/models` and `/effort` built-ins' way in (F7's twin). */} + setMenuOpen(true)} + /> + + )} + + + + e.preventDefault()} + align="start" + className="w-64" + > + + + Model + {selectedModel.label} + + { + // cmdk rows are not Radix items, so the menu is closed here. + setMenuOpen(false); + if (onSwitchModel) { + if (m.id !== switchId) + onSwitchModel(m.id === AUTO_MODEL_ID ? null : m); + return; + } + setModel(m.id); + onModelChange?.(m.id); + }} + /> + + {showEffort && ( + { + if (onSwitchModel) { + if (wire !== selectedEffort) onSwitchEffort?.(wire); + return; + } + setLocalEffort(wire); + onEffortChange?.(wire); + }} + /> + )} + {!onSwitchModel && ( + <> + + { + setModel(null); + setLocalEffort(DEFAULT_EFFORT); + onModelChange?.(null); + onEffortChange?.(DEFAULT_EFFORT); + }} + > + + Reset to default + + + )} + + + ); +} + +/** + * The operator posture tiers as the Mode menu offers them: plain words for + * a non-technical user. Values are the daemon's own ladder (rule 13) and + * saving one goes through the SAME controller document as Settings → + * Permissions (`saveHarnessPermissions`), which restarts the agent. + */ +const POSTURE_MENU_OPTIONS: readonly { + value: string; + label: string; + description: string; +}[] = [ + { + value: "strict", + label: "Strict", + description: "Asks before every change.", + }, + { + value: "trusted", + label: "Trusted", + description: "Follows this project's own rules, asks otherwise.", + }, + { + value: "auto", + label: "Auto", + description: "Works without asking. For unattended runs.", + }, + { + value: "yolo", + label: "Yolo", + description: "No safeguards. Only on a throwaway machine.", + }, +]; + +/** + * The Mode menu's "Safety level" control: the daemon-wide operator posture + * (the same setting as Settings → Permissions), offered where the user + * already picks how the agent asks. Managed mode only — in external mode + * the deployment owns the flag — and only once the controller has reported + * the saved document. Picking Auto/Yolo confirms first (they waive the ask + * before every change); the confirm dialog is rendered by the CALLER, + * outside the menu, because the menu closes (and unmounts its content) on + * the click. Saving restarts the agent; the runtime re-probe flips the + * checkmark once the new daemon answers. + */ +function usePostureControl() { + // Optional: a composer rendered outside the provider (tests, the mock + // tour, the thread panel) simply offers no safety-level rows. + const runtime = useOptionalRuntimeStatus(); + const { confirm, ConfirmDialog } = useConfirm(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const permissions = runtime?.permissions ?? null; + const available = runtime?.mode === "managed" && permissions !== null; + const current = permissions?.posture ?? ""; + + const pick = useCallback( + async (tier: string) => { + if (!permissions || tier === current || busy) return; + if (ALLOW_ALL_POSTURES.includes(tier)) { + const name = tier === "yolo" ? "Yolo" : "Auto"; + const ok = await confirm({ + title: `Switch to ${name}?`, + description: + tier === "yolo" + ? "The agent will make changes and run commands without asking, with no safeguards at all. Only use this on a throwaway machine. The agent restarts and anything running will stop." + : "The agent will make changes and run commands without asking first. The agent restarts and anything running will stop.", + confirmText: `Switch to ${name}`, + destructive: true, + }); + if (!ok) return; + } + setBusy(true); + setError(null); + try { + await saveHarnessPermissions({ ...permissions, posture: tier }); + await runtime?.refresh(); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "The safety level could not be changed.", + ); + } finally { + setBusy(false); + } + }, + [permissions, current, busy, confirm, runtime], + ); + + return { available, current, busy, error, pick, ConfirmDialog }; +} + +/** + * The composer's Safety pill: the daemon-wide operator posture as its own + * control next to Mode and Tools (see usePostureControl). Renders nothing + * where the posture cannot be changed from here (external mode, no + * runtime provider). The confirm dialog for Auto/Yolo renders as a sibling + * of the menu so it survives the menu closing. + */ +function SafetyLevelSelector({ + className, + disabled, +}: { + className?: string; + disabled?: boolean; +}) { + const posture = usePostureControl(); + if (!posture.available) return null; + const current = + POSTURE_MENU_OPTIONS.find((option) => option.value === posture.current) + ?.label ?? posture.current; + return ( + <> + + + + + e.preventDefault()} + align="start" + className="w-96" + > + {POSTURE_MENU_OPTIONS.map((option) => ( + void posture.pick(option.value)} + > + + + {option.label} + + {option.description} + + + + ))} + {posture.error ? ( +

+ {posture.error} +

+ ) : null} +
+
+ {posture.ConfirmDialog} + + ); +} + +/** + * The session permission-mode selector (Manual / Plan / Accept edits), the + * first control in the composer bar. Same pill + container-collapse idiom as + * the model selector: the current mode wide, the bare word "Mode" narrow, + * always the full selection on the title. The label carries the mode's + * colour cue (plan = info, accept edits = success, Manual plain) so the + * posture reads at a glance; ⇧Tab in the editor cycles it. A switch made + * mid-run is HELD by the caller until the run ends (the daemon refuses a + * mid-turn change) and reads "· pending" until the daemon confirms it; a + * caller without that contract disables the pill while streaming instead. + */ +export function ModeSelector({ + mode, + onModeChange, + disabled, + pending = false, +}: { + mode: SessionPermissionMode; + onModeChange: (mode: SessionPermissionMode) => void; + disabled?: boolean; + /** The shown `mode` is a held switch, not yet confirmed by the daemon. */ + pending?: boolean; +}) { + const label = permissionModeLabel(mode); + const shown = pending ? `${label} · pending` : label; + // Plan / Accept edits carry a filled dot in the box tint's hue (the TUI's + // mode-coloured rail), kept visible where the label collapses to "Mode". + const dot = modeDotClass(mode); + return ( + + + + + e.preventDefault()} + align="start" + className="w-72" + > + {PERMISSION_MODE_OPTIONS.map((option) => ( + onModeChange(option.id)} + > + + + {option.label} + + {option.description} + + + + ))} + + + ); +} + +/** + * The mobile composer's left-hand options button: a + that opens a bottom + * sheet with the composer's secondary actions — Add a file, Model — + * replacing the desktop toolbar row (hidden on mobile). Model + * drill into their own sheets; a routed model renders display-only. + */ +function MobileComposerMenu({ + onFilesSelected, + onModelChange, + modelLockedLabel, + onSwitchModel, + currentModelId, + mode, + onModeChange, + modeDisabled, + modePending = false, + profile, + onProfileChange, + models, + autoModelLabel, + effort, + onEffortChange, + onSwitchEffort, + currentEffort, + currentModelReasoning, + effortSupported = true, + onInsertFromMcp, +}: { + onFilesSelected: (files: File[]) => void; + /** Present when the daemon grants `capabilities.mcp`: opens the MCP prompt + * or resource picker (the desktop toolbar's "Insert from MCP"). */ + onInsertFromMcp?: (kind: McpPickerKind) => void; + /** Draft pick ("" = auto row); null = reset to untouched (Studio default). */ + onModelChange?: (id: string | null) => void; + modelLockedLabel?: string; + /** Present in a live chat: picking forks the chat onto the model (the + * daemon fixes a session's model at create). null = auto-routed. */ + onSwitchModel?: (option: ComposerModelOption | null) => void; + /** The live session's current model id ("" = auto), for the checkmark. */ + currentModelId?: string; + mode?: SessionPermissionMode; + onModeChange?: (mode: SessionPermissionMode) => void; + modeDisabled?: boolean; + /** The shown `mode` is a held switch awaiting the run's end ("· pending"). */ + modePending?: boolean; + /** The chat's tool profile (see ChatInput): rows in the Mode sheet on a + * draft, a read-only line on a live chat, "· No FS" on the Mode row. */ + profile?: SessionToolProfile; + onProfileChange?: (profile: SessionToolProfile) => void; + models?: ComposerModelOption[]; + autoModelLabel?: string; + /** Draft: the pending reasoning-effort WIRE value ("" = auto). */ + effort?: string; + onEffortChange?: (wire: string) => void; + /** Live chat: picking forks the chat onto the effort tier. */ + onSwitchEffort?: (wire: string) => void; + /** The live session's resolved_model.reasoning_effort ("" = auto). */ + currentEffort?: string; + /** The live session's model `reasoning` flag (false → warning row). */ + currentModelReasoning?: boolean; + /** False when the daemon's model_selection capability is off. */ + effortSupported?: boolean; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const [sub, setSub] = useState<"mode" | "model" | "memory" | null>(null); + // Draft: null = untouched (Studio default applies), "" = auto on purpose. + const [model, setModel] = useState(null); + const [localEffort, setLocalEffort] = useState(DEFAULT_EFFORT); + const fileInputRef = useRef(null); + const modelOptions = [autoModel(autoModelLabel), ...(models ?? [])]; + const { defaultModel: studioDefault } = useDefaultModel(); + const draft = resolveDraftModel(model, studioDefault, modelOptions); + const draftId = draft.id; + const selectedModel = + modelOptions.find((m) => m.id === draftId) ?? modelOptions[0]; + // Switch mode ignores the local pick state — the session's model is the + // truth, and a stale/disabled id still labels honestly as itself. + const switchId = currentModelId ?? AUTO_MODEL_ID; + const switchSelected = modelOptions.find((m) => m.id === switchId) ?? { + id: switchId, + label: switchId || autoModel(autoModelLabel).label, + }; + // Same two-mode split as the desktop selector: the draft's pending pick + // (caller-controlled when wired) vs the live session's EFFECTIVE tier. + const draftEffort = effort ?? localEffort; + const selectedEffort = onSwitchModel ? (currentEffort ?? "") : draftEffort; + const showEffort = + effortSupported && (onSwitchModel ? Boolean(onSwitchEffort) : true); + const modelReasoning = onSwitchModel + ? (currentModelReasoning ?? + modelOptions.find((m) => m.id === switchId)?.reasoning) + : selectedModel.reasoning; + const withEffort = (label: string) => + showEffort ? effortTriggerLabel(label, selectedEffort) : label; + + const menuRow = + "flex w-full items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/50 disabled:opacity-50"; + + return ( + <> + { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (files.length > 0) onFilesSelected(files); + }} + /> + {/* `/models` / `/effort` on a phone: open the sheet on its Model page. */} + {(onSwitchModel || onModelChange) && ( + { + setSub("model"); + setMenuOpen(true); + }} + /> + )} + + + + + Composer options +
+ + {onInsertFromMcp && ( + <> + + + + )} + {onModeChange && ( + + )} + +
+
+
+ + { + if (!open) setSub(null); + }} + > + + Permission mode +
+ {PERMISSION_MODE_OPTIONS.map((option) => ( + { + onModeChange?.(option.id); + setSub(null); + }} + /> + ))} + { + onProfileChange(next); + setSub(null); + } + : undefined + } + /> +
+
+
+ + { + if (!open) setSub(null); + }} + > + + Model and effort +
+ Model + { + if (onSwitchModel) { + setSub(null); + if (m.id !== switchId) + onSwitchModel(m.id === AUTO_MODEL_ID ? null : m); + return; + } + setModel(m.id); + onModelChange?.(m.id); + }} + /> + {showEffort && ( + { + if (onSwitchModel) { + setSub(null); + if (wire !== selectedEffort) onSwitchEffort?.(wire); + return; + } + setLocalEffort(wire); + onEffortChange?.(wire); + }} + /> + )} + {!onSwitchModel && ( + <> +
+ + + )} +
+ + + + ); +} + +function ProjectsDropdown({ + projects, + selectedProjectId, + onSelect, + onCreate, +}: { + projects?: ProjectItem[]; + selectedProjectId?: string | null; + onSelect?: (id: string | null) => void; + onCreate?: (name: string) => void; +}) { + const { prompt, PromptDialog } = usePrompt(); + const selected = projects?.find((p) => p.id === selectedProjectId); + + return ( + <> + {PromptDialog} + + + + + e.preventDefault()} + align="start" + className="w-56" + > + Projects + + + onSelect?.(null)}> + + Chats + + + {projects?.map((p) => ( + onSelect?.(p.id)}> + + + {p.name} + + + ))} + + + { + const name = await prompt({ + title: "New project", + description: + "Give your project a name to organize related conversations.", + placeholder: "Project name", + confirmText: "Create project", + }); + if (name) onCreate?.(name); + }} + > + + New project + + + + + ); +} + +function useVoiceInput(onTranscript: (text: string) => void) { + const [isListening, setIsListening] = useState(false); + const [isSupported, setIsSupported] = useState(false); + const recognitionRef = useRef | null>(null); + + useEffect(() => { + const SR = + typeof window !== "undefined" + ? window.SpeechRecognition || window.webkitSpeechRecognition + : null; + setIsSupported(!!SR); + }, []); + + const toggle = useCallback(() => { + if (isListening) { + recognitionRef.current?.stop(); + setIsListening(false); + return; + } + + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SR) return; + + const recognition = new SR(); + recognition.continuous = false; + recognition.interimResults = true; + recognition.lang = "en-US"; + + let finalText = ""; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + let interim = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + finalText += transcript; + } else { + interim += transcript; + } + } + onTranscript(finalText + interim); + }; + + recognition.onend = () => { + setIsListening(false); + if (finalText) onTranscript(finalText); + }; + + recognition.onerror = () => { + setIsListening(false); + }; + + recognitionRef.current = recognition; + recognition.start(); + setIsListening(true); + }, [isListening, onTranscript]); + + return { isListening, isSupported, toggle }; +} + +type SpeechRecognitionType = new () => { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onend: (() => void) | null; + onerror: ((event: Event) => void) | null; + start: () => void; + stop: () => void; +}; + +declare global { + interface Window { + SpeechRecognition: SpeechRecognitionType; + webkitSpeechRecognition: SpeechRecognitionType; + } +} + +const DEFAULT_PLACEHOLDER = + "Pull in tools and expertise by including @agent, @file or +Files"; + +/** + * The `@` menu: the file rows first — "Attach a file…" while the query could + * still spell "file", "Mention path @…" for a path-shaped token — then the + * agent list filtered by handle prefix or name substring. Exported for its + * unit test. + */ +export function agentMenuItems(query: string): ComposerMenuItem[] { + const q = query.toLowerCase(); + const agents = getAgentMentions() + .filter((a) => a.handle.startsWith(q) || a.name.toLowerCase().includes(q)) + .map((a) => ({ + id: a.handle, + label: a.handle, + primary: a.name, + secondary: a.description, + })); + return [...fileMenuRows(query), ...agents]; +} + +/** + * Filter the slash-command list for the `/`-command menu by name prefix. + * Studio's own builtins (`/clear /help /session /retry /diagnostics + * /compact`, in that fixed order, `gates` hiding the capability-gated ones) + * come first and shadow a daemon command of the same name, so what the menu + * shows is what fires; `builtins: false` (a surface with no local-command + * handler) lists the daemon's commands only. Exported for its unit test. + */ +export function commandMenuItems( + query: string, + options: { builtins?: boolean; gates?: BuiltinGates } = {}, +): ComposerMenuItem[] { + const q = query.toLowerCase(); + const builtins = + options.builtins === false + ? [] + : builtinSlashCommands(options.gates ?? CLOSED_BUILTIN_GATES).filter( + (c) => c.name.startsWith(q), + ); + const shadowed = new Set(builtins.map((c) => c.name)); + const daemon = getSlashCommands().filter( + (c) => c.name.startsWith(q) && !shadowed.has(c.name), + ); + return [ + ...builtins.map((c) => ({ + id: c.name, + label: c.name, + primary: `/${c.name}`, + secondary: c.description, + builtin: true, + })), + ...daemon.map((c) => ({ + id: c.name, + label: c.name, + primary: `/${c.name}`, + secondary: c.description, + })), + ]; +} + +export type ComposerSubmission = + /** Ordinary text (or a daemon workspace command): steer/queue/send. */ + | { readonly action: "pass" } + /** A bare Studio built-in: run it locally, never send it. */ + | { readonly action: "builtin"; readonly name: StudioBuiltinCommand } + /** A held (arguments, second line) or gated-off built-in: keep the text + * in the editor and show the warning. */ + | { readonly action: "hold"; readonly warning: string }; + +/** + * The composer's built-in interception, extracted pure: what a submission + * does BEFORE the steer/queue/send branches. Only where a handler is wired + * (`hasHandler`); otherwise every text passes to the agent. Deliberately + * independent of `isStreaming` — a built-in typed mid-run is intercepted the + * same way, never queued or steered. Exported for its unit test. + */ +export function resolveComposerSubmission(input: { + text: string; + gates?: BuiltinGates; + hasHandler: boolean; + isStreaming?: boolean; +}): ComposerSubmission { + if (!input.hasHandler) return { action: "pass" }; + const classified = classifySlashLine( + input.text, + input.gates ?? CLOSED_BUILTIN_GATES, + ); + switch (classified.kind) { + case "builtin": + return { action: "builtin", name: classified.name }; + case "held": + case "gated": + return { action: "hold", warning: classified.reason }; + default: + return { action: "pass" }; + } +} + +/** + * One attached-file chip. Image files show a small thumbnail of the file + * itself (an object URL — cheaper than a data-URL read) before the name; the + * URL is revoked when the pill unmounts (remove/send), so attach/remove + * cycles never leak blob URLs. Non-image files (and an image whose thumbnail + * has not resolved yet) show their file-kind glyph — image/PDF/code — with + * the paperclip as the fallback. Exported for its unit test. + */ +export function AttachmentPill({ + file, + onRemove, + onPreview, +}: { + file: File; + onRemove: () => void; + /** Opens the file in the canvas panel (main composer only). */ + onPreview?: () => void; +}) { + const [previewUrl, setPreviewUrl] = useState(null); + useEffect(() => { + if (!file.type.startsWith("image/")) { + setPreviewUrl(null); + return; + } + const url = URL.createObjectURL(file); + setPreviewUrl(url); + return () => URL.revokeObjectURL(url); + }, [file]); + const kind = fileKindMeta(file.name, file.type); + const KindIcon = kind.icon; + + return ( + + + + + ); +} + +/** What Enter resolves to in the composer. `newline` means "do not intercept + * the key" — the editor's own hardBreak handles it. */ +export type ComposerEnterAction = "send" | "queue" | "steer" | "newline"; + +/** + * The composer's Enter decision table, extracted pure so the whole matrix is + * testable without driving the TipTap editor: + * + * - Idle: Enter sends; Shift+Enter inserts a newline (fall through to the + * editor's hardBreak). + * - Streaming: Enter performs the preferred action (Settings → Personalize) and + * Shift+Enter the opposite. Attachments no longer force the queue path — + * steers carry image parts (ADR 0251), so a mid-run send with files steers + * when steering is available (and degrades to queue when it is not, via + * performAction's missing-handler fallback, keeping the files attached). + * - ⌘Enter / Ctrl+Enter (`mod`): ALWAYS a newline — idle or streaming, with + * or without text, whatever the preference — the unconditional form the + * TUI binds to ctrl+j (the editor's own Mod-Enter hardBreak inserts it). + */ +export function resolveComposerAction(input: { + shift: boolean; + /** ⌘ or Ctrl held with Enter. Absent = not held. */ + mod?: boolean; + isStreaming: boolean; + behavior: EnterSendBehavior; +}): ComposerEnterAction { + if (input.mod) return "newline"; + if (!input.isStreaming) return input.shift ? "newline" : "send"; + // "Queue only" (the client-level never-steer switch, mecatui --no-steer): + // Enter AND Shift+Enter queue — there is no opposite to invert into. + if (input.behavior === "queue-only") return "queue"; + if (!input.shift) return input.behavior; + return input.behavior === "queue" ? "steer" : "queue"; +} + +/** What a key does to the held queue from an EMPTY composer. */ +export type EmptyComposerQueueAction = "resume" | "edit" | "clear"; + +/** + * The empty-composer queue keys, extracted pure (the TUI's paused-queue + * gestures): only with something queued and no autocomplete menu open — + * + * - Enter, idle: send the held queue now (`resume`). + * - ↑, streaming or idle: pull the held queue back for editing (`edit`). + * - Esc, idle: drop the held queue (`clear`) — while streaming Esc keeps + * its run-cancel meaning (the global close.esc), so null. + * + * The caller has already established the composer is empty; a non-empty + * composer keeps every key's ordinary editing meaning. + */ +export function resolveEmptyComposerKey(input: { + key: string; + queuedCount: number; + isStreaming: boolean; + menuOpen: boolean; +}): EmptyComposerQueueAction | null { + if (input.menuOpen || input.queuedCount <= 0) return null; + switch (input.key) { + case "Enter": + return input.isStreaming ? null : "resume"; + case "ArrowUp": + return "edit"; + case "Escape": + return input.isStreaming ? null : "clear"; + default: + return null; + } +} + +/** Open autocomplete menu state, mirrored from TipTap's suggestion lifecycle. */ +interface ComposerMenu { + kind: "agent" | "command"; + items: ComposerMenuItem[]; + index: number; + /** The text typed after the trigger char (the no-match hint names it). */ + query: string; + select: (item: ComposerMenuItem) => void; +} + +/** The muted source tag on a `/` row: Studio's own or the daemon's. */ +export function commandSourceTag(item: ComposerMenuItem): string { + return item.builtin ? "built-in" : "workspace"; +} + +/** + * The `/` palette's empty state, shown INSTEAD of nothing when the typed + * token matches no command: names the token and says what Enter does (the + * text goes to the agent unchanged). Null for the `@` menu and for an + * empty query (the bare `/` with no commands at all shows no popover). + */ +export function commandNoMatchHint(menu: { + kind: "agent" | "command"; + items: readonly unknown[]; + query: string; +}): string | null { + if (menu.kind !== "command" || menu.items.length > 0 || !menu.query) { + return null; + } + return `No command matches /${menu.query} — Enter sends it as text`; +} + +/** + * Drive the open autocomplete menu from a keydown. Returns true when the key + * was for the menu (arrows move the highlight, Enter/Tab pick the row, Escape + * closes it). Selection is deferred a microtask so the chip insert lands after + * ProseMirror finishes dispatching the key. + */ +function handleMenuNavKey( + event: KeyboardEvent, + menu: ComposerMenu, + setMenu: React.Dispatch>, +): boolean { + if (menu.items.length === 0) return false; + switch (event.key) { + case "ArrowDown": + setMenu((m) => (m ? { ...m, index: (m.index + 1) % m.items.length } : m)); + return true; + case "ArrowUp": + setMenu((m) => + m + ? { ...m, index: (m.index - 1 + m.items.length) % m.items.length } + : m, + ); + return true; + case "Enter": + case "Tab": { + const item = menu.items[menu.index]; + if (item) queueMicrotask(() => menu.select(item)); + return true; + } + case "Escape": + setMenu(null); + return true; + default: + return false; + } +} + +export function ChatInput({ + placeholder: placeholderProp, + projects, + selectedProjectId, + onSelectProject, + onCreateProject, + rows, + compact = false, + mobileDocked = false, + focusKey, + onSend, + onQueue, + onSteer, + onModelChange, + disabled = false, + isStreaming = false, + modeSwitchDeferred = false, + pendingMode = null, + appendText, + onAppendConsumed, + initialText, + initialFiles, + onInitialTextConsumed, + queuedCount = 0, + onResumeQueue, + onEditAllQueued, + onClearQueue, + modelLockedLabel, + onSwitchModel, + currentModelId, + effort, + onEffortChange, + onSwitchEffort, + currentEffort, + currentModelReasoning, + effortSupported, + models, + autoModelLabel, + onPreviewAttachment, + mode, + onModeChange, + profile, + onProfileChange, + onLocalCommand, + builtinGates, + mediaCapabilities, + escapePress, + onDraftChange, + draftKey, +}: ChatInputProps) { + const placeholder = placeholderProp ?? DEFAULT_PLACEHOLDER; + // Plain-text mirror of the editor, kept in sync via onUpdate. Used only for + // "is there something to send" checks and the streaming border state; + // the editor document is the source of truth for the message itself. + const [text, setText] = useState(""); + const [attachedFiles, setAttachedFiles] = useState([]); + // A built-in's local warning (held arguments, a gated-off command, a + // refused dispatch), shown above the input until the next edit. + const [notice, setNotice] = useState(null); + const [isDragOver, setIsDragOver] = useState(false); + const [isWindowDrag, setIsWindowDrag] = useState(false); + const dragCountRef = useRef(0); + const windowDragCountRef = useRef(0); + + // Autocomplete menu, mirrored from TipTap's suggestion lifecycle so we can + // render the same full-width popover the pre-TipTap composer used. `menuRef` + // gives the suggestion keydown handler a synchronous read of current state. + const [menu, setMenu] = useState(null); + + // The hidden file input behind "+", shared with the `@` menu's "Attach a + // file…" row so both open the one native picker. + const attachInputRef = useRef(null); + // The staging gate (the TUI's `attach:` refusal, at stage time): a file + // the session's modalities reject — or a text file too big to inline — is + // never staged; its reason shows above the input like a built-in's + // warning. With no gate supplied only the size limits apply. + const stageFiles = useCallback( + (incoming: File[]) => { + const gate = mediaCapabilities ?? { image: true, audio: true }; + const accepted: File[] = []; + const reasons: string[] = []; + for (const file of incoming) { + const verdict = classifyAttachment(file, gate); + if (verdict.kind === "rejected") reasons.push(verdict.reason); + else accepted.push(file); + } + if (accepted.length > 0) { + setAttachedFiles((prev) => [...prev, ...accepted]); + } + if (reasons.length > 0) setNotice(reasons.join(" ")); + }, + [mediaCapabilities], + ); + + // The Studio-local command handler, read through a ref so the suggestion + // plugins (built once) always see the latest prop without rebuilding the + // editor's extensions. Updated in an effect, never during render. + const onLocalCommandRef = useRef(onLocalCommand); + useEffect(() => { + onLocalCommandRef.current = onLocalCommand; + }, [onLocalCommand]); + // The palette's gate set, read per keystroke by the suggestion plugin + // (which lives outside React's render), so a ref rather than a dep. + const builtinGatesRef = useRef(builtinGates); + builtinGatesRef.current = builtinGates; + + // Picking a menu row. A Studio builtin (`/help`) acts on selection: the + // composer clears and the handler runs, so ONE Enter on the menu is enough + // and no chip is ever inserted. Every other row inserts its atomic chip via + // the suggestion's `command`. + const selectMenuItem = useCallback( + ( + kind: "agent" | "command", + props: SuggestionProps, + item: ComposerMenuItem, + ) => { + if (kind === "agent" && isAttachFileItem(item)) { + // "Attach a file…": no chip — drop the `@` token, open the picker. + setMenu(null); + props.editor.chain().focus().deleteRange(props.range).run(); + attachInputRef.current?.click(); + return; + } + const pathText = kind === "agent" ? pathMentionText(item) : null; + if (pathText !== null) { + // "Mention path": plain text the model reads (and can Read from the + // workspace) — never a chip, and no completion is claimed. + setMenu(null); + props.editor + .chain() + .focus() + .insertContentAt(props.range, { type: "text", text: `${pathText} ` }) + .run(); + return; + } + const handler = onLocalCommandRef.current; + if (kind === "command" && handler && isStudioBuiltinCommand(item.id)) { + setMenu(null); + const outcome = handler(item.id); + if (outcome && outcome.ok === false) { + // Refused: show the full command with the reason above it, so + // what the warning names is what the field holds. + setComposerText(props.editor, `/${item.id}`); + setNotice(outcome.warning); + return; + } + props.editor.commands.clearContent(); + setText(""); + if (outcome?.insertText) { + // `/agents`: type the `@` into the emptied field so the agent + // roster opens (the suggestion plugin reads the document). + props.editor.chain().focus().insertContent(outcome.insertText).run(); + } + return; + } + props.command(item); + }, + [], + ); + + // Wire each mention's TipTap suggestion to the shared menu state. `command` + // (from the suggestion props) inserts the atomic chip at the trigger range. + // Keyboard navigation is handled by the editor's handleKeyDown (below), not + // here, so the suggestion's own onKeyDown is intentionally omitted. + const makeRender = useCallback( + (kind: "agent" | "command") => () => ({ + onStart: (props: SuggestionProps) => { + setMenu({ + kind, + items: props.items, + index: 0, + query: props.query, + select: (item) => selectMenuItem(kind, props, item), + }); + }, + onUpdate: (props: SuggestionProps) => { + setMenu((m) => { + if (!m || m.kind !== kind) return m; + const index = props.items.length + ? Math.min(m.index, props.items.length - 1) + : 0; + return { + kind, + items: props.items, + index, + query: props.query, + select: (item) => selectMenuItem(kind, props, item), + }; + }); + }, + onExit: () => setMenu((m) => (m && m.kind === kind ? null : m)), + }), + [selectMenuItem], + ); + + const mentions = useMemo( + () => + createComposerMentions({ + agentItems: agentMenuItems, + // Builtins only where a handler can answer them; otherwise the menu + // is the daemon's list alone and `/help` would go to the agent. + commandItems: (query) => + commandMenuItems(query, { + builtins: onLocalCommandRef.current != null, + gates: builtinGatesRef.current, + }), + makeRender, + }), + [makeRender], + ); + + const editor = useEditor({ + immediatelyRender: false, + autofocus: mobileViewport() ? false : "end", + extensions: [ + // A deliberately plain field: keep the editing primitives (undo, hard + // break, drop/gap cursors) but drop every rich-text mark and block so + // typing `# `, `**`, `- ` etc. stays literal, exactly like the textarea. + StarterKit.configure({ + heading: false, + bold: false, + italic: false, + strike: false, + code: false, + codeBlock: false, + blockquote: false, + bulletList: false, + orderedList: false, + listItem: false, + horizontalRule: false, + link: false, + underline: false, + }), + Placeholder.configure({ placeholder }), + // The `[Pasted text #N]` chip a large paste is staged behind; its + // renderText is the payload, so composerText expands it on send. + PastePlaceholder, + ...mentions, + ], + onUpdate: ({ editor }) => { + setText(editor.getText({ blockSeparator: "\n" })); + setNotice(null); + }, + }); + + // "Insert from MCP" (the TUI's f8 prompts / ctrl+r resources pickers): + // capability-gated on the daemon's `mcp`; the picked text is appended + // after the draft for review, never sent. + const mcpInsert = useMcpComposerInsert(editor); + + // The one-shot draft clear (the TUI's ctrl+u ClearPrompt): editor content + // — `[Pasted text #N]` chips included — the text mirror, the staged files + // and a built-in's warning, all at once. Shared by the ⌘⇧U chord, the × + // button beside Send and the double-Esc guard below; the emptied text + // mirror also drops the persisted draft (the guards' text effect). + const clearDraft = useCallback(() => { + editor?.commands.clearContent(); + setText(""); + setAttachedFiles([]); + setNotice(null); + }, [editor]); + const clearDraftFromButton = useCallback(() => { + clearDraft(); + editor?.commands.focus(); + }, [clearDraft, editor]); + + // The draft guards (the TUI's esc-esc clear and two-step quit, as their + // web analogues): a double Esc empties an idle draft, the unsent text is + // persisted under `draftKey`, and `onDraftChange` feeds the leave guard. + const { escapeArmed } = useComposerDraftGuards({ + editor, + text, + setText, + escapePress, + draftKey, + initialTextPending: Boolean(initialText), + onDraftChange, + clearComposer: clearDraft, + }); + + // Entering a chat or thread puts the caret in the field; autofocus only + // covers the first mount, so a change of target refocuses explicitly. + // Desktop only — see mobileViewport above. + useEffect(() => { + if (focusKey !== undefined && !mobileViewport()) + editor?.commands.focus("end"); + }, [focusKey, editor]); + + useEffect(() => { + editor?.setEditable(!disabled); + }, [editor, disabled]); + + useEffect(() => { + if (appendText && editor) { + const raw = editor.getText({ blockSeparator: "\n" }); + const sep = raw && !raw.endsWith("\n") ? "\n" : ""; + setComposerText(editor, `${raw}${sep}> ${appendText}\n`); + setText(editor.getText({ blockSeparator: "\n" })); + onAppendConsumed?.(); + requestAnimationFrame(() => editor.commands.focus("end")); + } + }, [appendText, onAppendConsumed, editor]); + + useEffect(() => { + if (!editor) return; + const hasFiles = (initialFiles?.length ?? 0) > 0; + if (!initialText && !hasFiles) return; + if (initialText) { + setComposerText(editor, initialText); + setText(editor.getText({ blockSeparator: "\n" })); + } + // A pulled-back queued message brings its staged files with it. + if (initialFiles && hasFiles) { + setAttachedFiles((prev) => [...prev, ...initialFiles]); + } + onInitialTextConsumed?.(); + requestAnimationFrame(() => editor.commands.focus("end")); + }, [initialText, initialFiles, onInitialTextConsumed, editor]); + + useEffect(() => { + const onEnter = (e: DragEvent) => { + if (e.dataTransfer?.types.includes("Files")) { + windowDragCountRef.current++; + setIsWindowDrag(true); + } + }; + const onLeave = () => { + windowDragCountRef.current--; + if (windowDragCountRef.current <= 0) { + windowDragCountRef.current = 0; + setIsWindowDrag(false); + } + }; + const onDrop = () => { + windowDragCountRef.current = 0; + setIsWindowDrag(false); + }; + window.addEventListener("dragenter", onEnter); + window.addEventListener("dragleave", onLeave); + window.addEventListener("drop", onDrop); + return () => { + window.removeEventListener("dragenter", onEnter); + window.removeEventListener("dragleave", onLeave); + window.removeEventListener("drop", onDrop); + }; + }, []); + + const voice = useVoiceInput( + useCallback( + (transcript: string) => { + if (editor) setComposerText(editor, transcript); + setText(transcript); + }, + [editor], + ), + ); + + // The preferred Enter action while a reply is streaming (Settings → Personalize). + // Hydrates on mount, so the first frame is always the "queue" default. + const { behavior: enterBehavior } = useEnterSendBehavior(); + + /** Executes one resolved Enter action against the current editor content. + * Missing handlers degrade toward the pre-steer behavior: steer without + * onSteer queues, queue without onQueue sends. */ + const performAction = useCallback( + (action: ComposerEnterAction) => { + if (action === "newline") return; + const trimmed = editor ? composerText(editor) : ""; + if (!trimmed || disabled) return; + // A Studio built-in is answered here whatever the action — typed + // mid-stream it runs (or is refused with a warning), never queues or + // steers. Only where the surface wired a handler; otherwise the text + // goes to the agent like any other message. + const submission = resolveComposerSubmission({ + text: trimmed, + gates: builtinGates, + hasHandler: onLocalCommand != null, + }); + if (submission.action === "hold") { + setNotice(submission.warning); + return; + } + if (submission.action === "builtin" && onLocalCommand) { + const outcome = onLocalCommand(submission.name); + if (outcome && outcome.ok === false) { + setNotice(outcome.warning); + return; + } + editor?.commands.clearContent(); + setText(""); + if (outcome?.insertText && editor) { + // `/agents` typed out: the `@` opens the roster in the emptied field. + editor.chain().focus().insertContent(outcome.insertText).run(); + } + return; + } + const files = attachedFiles.length > 0 ? attachedFiles : undefined; + const resolved = action === "steer" && !onSteer ? "queue" : action; + if (resolved === "steer" && onSteer) { + // A steer carries the staged attachments as image parts (ADR 0251); + // they leave the composer with the text. + onSteer(trimmed, files); + editor?.commands.clearContent(); + setText(""); + setAttachedFiles([]); + return; + } + if (resolved === "queue" && onQueue) { + // The staged files travel with the queued text (and come back with + // it when the row is edited). + onQueue(trimmed, files); + editor?.commands.clearContent(); + setText(""); + setAttachedFiles([]); + return; + } + onSend?.(trimmed, files); + editor?.commands.clearContent(); + setText(""); + setAttachedFiles([]); + }, + [ + editor, + disabled, + onQueue, + onSteer, + onSend, + onLocalCommand, + builtinGates, + attachedFiles, + ], + ); + + /** Resolve + perform for one Enter press (or a send-button click, which is + * the plain-Enter path). */ + const actOnEnter = useCallback( + (shift: boolean) => { + performAction( + resolveComposerAction({ + shift, + isStreaming, + behavior: enterBehavior, + }), + ); + }, + [performAction, isStreaming, enterBehavior], + ); + + const handleSend = useCallback(() => actOnEnter(false), [actOnEnter]); + + // The permission mode as SHOWN: a held mid-run switch (`pendingMode`) over + // the daemon-confirmed `mode`. ⇧Tab cycles onward from what is shown and + // the pill/box tint follow it. The cycle key and the pill share ONE + // enablement, so the key is never live where the pill is greyed out. + const shownMode: SessionPermissionMode = pendingMode ?? mode ?? "default"; + const modePending = + pendingMode != null && pendingMode !== (mode ?? "default"); + const canChangeMode = + !!onModeChange && !disabled && (!isStreaming || modeSwitchDeferred); + + // Menu nav + Enter-to-send are wired with a native capture-phase keydown + // listener on the editor DOM, re-subscribed each render with fresh closures + // over `menu`/`actOnEnter`. Capture phase runs before ProseMirror's own + // (bubble-phase) handler, and stopPropagation keeps the base keymap and the + // suggestion plugins from also acting. This sidesteps both TipTap re-syncing + // its editorProps and the React Compiler not preserving render-phase refs. + useEffect(() => { + const dom = editor?.view.dom; + if (!dom) return; + const onKeyDown = (event: KeyboardEvent) => { + // ⇧Tab cycles the permission mode (the TUI's shift+tab) — from the + // editor only, and never over an open `/` or `@` menu, where Tab picks + // the highlighted row (`resolveModeCycleKey` owns the table). A held + // mid-run switch is the caller's; a surface that can't change the mode + // leaves the key to the browser's reverse-focus. + if ( + resolveModeCycleKey({ + key: event.key, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + menuOpen: menu != null, + canChangeMode, + }) + ) { + event.preventDefault(); + event.stopPropagation(); + onModeChange?.(nextPermissionMode(shownMode)); + return; + } + // ⌘⇧U empties the whole draft (the TUI's ctrl+u ClearPrompt) — text, + // paste chips, staged files — from the editor only; the × button and + // the double-Esc guard are the same clear. Idle or streaming: it never + // touches the run. Ahead of the menu: clearing a `/foo` draft closes + // its menu with it. + if (isClearDraftChord(event)) { + event.preventDefault(); + event.stopPropagation(); + clearDraft(); + return; + } + if (menu) { + if (handleMenuNavKey(event, menu, setMenu)) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + // The held-queue gestures live on an EMPTY composer only (Enter sends + // the queue, ↑ pulls it back, Esc clears it); a bare key, no chords. + if ( + queuedCount > 0 && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + (editor ? composerText(editor) : "") === "" + ) { + const queueAction = resolveEmptyComposerKey({ + key: event.key, + queuedCount, + isStreaming, + menuOpen: false, + }); + if (queueAction) { + // preventDefault also keeps the global close.esc from firing on + // the same Escape. + event.preventDefault(); + event.stopPropagation(); + if (queueAction === "resume") onResumeQueue?.(); + else if (queueAction === "edit") onEditAllQueued?.(); + else onClearQueue?.(); + return; + } + } + if (event.key !== "Enter") return; + // ⌘Enter / Ctrl+Enter is ALWAYS a new line (the TUI's ctrl+j) — idle or + // streaming, with or without text: fall through to the editor's own + // Mod-Enter hardBreak without preventDefault, never into send/queue/ + // steer (`resolveComposerAction` says the same for `mod`). + if (isAlwaysNewlineChord(event)) return; + if (event.shiftKey) { + // Shift+Enter acts (as the opposite of the Enter preference) only + // while a reply is streaming AND there is text to act on; otherwise + // it keeps its newline behavior — fall through to the editor's + // hardBreak without preventDefault. + const trimmed = editor ? composerText(editor) : ""; + if (!isStreaming || !trimmed) return; + event.preventDefault(); + event.stopPropagation(); + actOnEnter(true); + return; + } + event.preventDefault(); + event.stopPropagation(); + actOnEnter(false); + }; + dom.addEventListener("keydown", onKeyDown, true); + return () => dom.removeEventListener("keydown", onKeyDown, true); + }, [ + editor, + menu, + isStreaming, + actOnEnter, + clearDraft, + queuedCount, + onResumeQueue, + onEditAllQueued, + onClearQueue, + canChangeMode, + shownMode, + onModeChange, + ]); + + // Paste (the TUI's ctrl+v). Clipboard files with no text (a screenshot) + // stage through the same gate as the picker and drop; a LARGE text paste + // (≥ 2000 chars alone or with the current text, or ≥ 30 lines) becomes one + // atomic `[Pasted text #N]` chip that composerText expands on send, steer + // and queue; anything smaller falls through to ProseMirror's own paste. + // Capture-phase on the editor DOM like the keydown listener above, so a + // handled paste stops before ProseMirror's bubble-phase handler would + // insert the text too. Inert while disabled (the editor is read-only). + const pasteCounterRef = useRef(0); + useEffect(() => { + // N restarts with an empty composer (a send, a wipe); within one draft + // it is monotonic, so a deleted chip leaves a numbering gap, as in the TUI. + if (text === "") pasteCounterRef.current = 0; + }, [text]); + useEffect(() => { + const dom = editor?.view.dom; + if (!editor || !dom) return; + const onPaste = (event: ClipboardEvent) => { + if (disabled) return; + const outcome = applyComposerPaste( + editor, + readPasteClipboard(event.clipboardData), + { + stageFiles, + nextNumber: () => ++pasteCounterRef.current, + }, + ); + if (outcome === "default") return; + event.preventDefault(); + event.stopPropagation(); + }; + dom.addEventListener("paste", onPaste, true); + return () => dom.removeEventListener("paste", onPaste, true); + }, [editor, disabled, stageFiles]); + + const hasText = text.trim().length > 0; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: drop zone for file attachments +
{ + e.preventDefault(); + dragCountRef.current++; + setIsDragOver(true); + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={(e) => { + e.preventDefault(); + dragCountRef.current--; + if (dragCountRef.current <= 0) { + dragCountRef.current = 0; + setIsDragOver(false); + } + }} + onDrop={(e) => { + e.preventDefault(); + dragCountRef.current = 0; + setIsDragOver(false); + // Any file: images/audio as media parts, the rest inlined as text; + // the staging gate refuses what this session cannot take. + const droppedFiles = Array.from(e.dataTransfer.files); + if (droppedFiles.length > 0) stageFiles(droppedFiles); + }} + className={cn( + "relative rounded-2xl bg-zinc-50 dark:bg-zinc-900", + mobileDocked && "max-[499px]:rounded-none max-[499px]:bg-transparent", + )} + > + {/* Autocomplete popover (agent @-mentions or slash commands), floating + above the input box. Driven by TipTap's suggestion lifecycle. */} + {menu && menu.items.length === 0 && commandNoMatchHint(menu) && ( + // The `/` palette's empty state: not a row (nothing to pick — Enter + // falls through to the plain send), just the hint. +
+

+ {commandNoMatchHint(menu)} +

+
+ )} + {menu && menu.items.length > 0 && ( +
+
+ {menu.items.map((item, i) => ( + + ))} +
+
+ )} + {/* A built-in's local warning: the typed text stays in the field. */} + {notice && ( +

+

+ )} + {/* The double-Esc arm (the TUI's esc-esc): a second Esc inside the + window empties the draft; any edit disarms. */} + {escapeArmed && ( +

+ {ESCAPE_ARMED_HINT} +

+ )} + {/* Input box: textarea + inline toolbar (+, mic) + send button. + Has its own rounded border. The toolbar row below has a matching + border on its top/sides/bottom; the two borders meet along the + input box's bottom edge, sharing a single visible line. */} +
window drag > streaming with a + // draft > mode tint (Plan / Accept edits: the TUI recolours its + // input by mode; Manual keeps the plain border). The table is + // composerFrameClass; a surface with no Mode selector never tints. + composerFrameClass({ + mode: onModeChange ? shownMode : undefined, + isDragOver, + isWindowDrag, + isStreaming, + hasText, + }), + )} + > + {voice.isListening && ( +
+ + + Listening + +
+ )} + {isDragOver && ( +
+
+ + Drop files to attach +
+
+ )} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((f, i) => ( + onPreviewAttachment(f) : undefined + } + // biome-ignore lint/suspicious/noArrayIndexKey: files may share names + key={`${f.name}-${i}`} + file={f} + onRemove={() => + setAttachedFiles((prev) => prev.filter((_, j) => j !== i)) + } + /> + ))} +
+ )} + {/* Mobile consolidates to ONE line — + on the left, the field, and a + single right slot that is the mic until there is text, then the + send button (native messaging convention). Desktop keeps the + two-row layout below. */} + {/* min-h-14 mirrors the chat header bar, so the docked composer and + the title bar read as symmetric top/bottom bands. */} +
+
+ +
+ {/* TipTap composer: resolved @agent / /skill mentions are atomic + green chips (Backspace removes a whole chip); Enter sends and + Shift+Enter inserts a newline (handled in the editor keymap). */} +
+ +
+ {/* Mobile: × clears the whole draft (text + staged files) beside + the mic/send slot; hidden while there is nothing to clear. */} +
+ 0} + disabled={disabled} + onClear={clearDraftFromButton} + /> +
+
+ {!hasText && voice.isSupported ? ( + + ) : ( + + )} +
+
+ {/* Desktop-only bottom row: attach (+), mic on the left; send right */} +
+ + + {voice.isSupported && ( + + )} + {/* Right: × clears the whole draft (the TUI's ctrl+u; also ⌘⇧U + and Esc Esc), then Send. The × shows only with something staged. */} +
+ 0} + disabled={disabled} + onClear={clearDraftFromButton} + /> + +
+
+
+ {/* Toolbar sits BEHIND the input box: negative top margin pulls it up + so its top edge overlaps the input box's bottom rounded corners, + making the two boxes appear to share a single outline. */} + {/* Hidden on mobile: Model lives in the + options sheet. */} + {/* @container: the Model/Memory pills collapse their value labels via + container queries when THIS row runs narrow (a ~400px side-panel + composer), independent of the viewport width. */} +
+ {projects && ( + + )} + {!compact && ( + <> + {onModeChange && ( + + )} + {onModeChange && ( + + )} + {onModeChange && ( + + )} + { + onModelChange?.(id); + }} + /> + + )} +
+ {mcpInsert.dialogs} +
+ ); +} diff --git a/studio/src/app/workspace/_components/clear-draft-button.test.tsx b/studio/src/app/workspace/_components/clear-draft-button.test.tsx new file mode 100644 index 0000000000..0865641f00 --- /dev/null +++ b/studio/src/app/workspace/_components/clear-draft-button.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { + CLEAR_DRAFT_LABEL, + CLEAR_DRAFT_TITLE, + ClearDraftButton, +} from "./clear-draft-button"; + +/** + * The composer's × (the TUI's ctrl+u ClearPrompt as a button): present only + * while something is staged, labelled for assistive tech, hinting the chord + * on hover, and inert while the composer is read-only. + */ +describe("ClearDraftButton", () => { + it("renders nothing while the composer is empty", () => { + const { container } = render( + {}} />, + ); + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("is a labelled button that fires onClear once per click", async () => { + const onClear = vi.fn(); + render(); + const button = screen.getByRole("button", { name: CLEAR_DRAFT_LABEL }); + // The hover hint names the chord that does the same thing. + expect(button).toHaveAttribute("title", CLEAR_DRAFT_TITLE); + expect(CLEAR_DRAFT_TITLE).toBe("Clear draft (⌘⇧U)"); + // Never a submit button — the composer sits inside no form, but a + // future one must not send on ×. + expect(button).toHaveAttribute("type", "button"); + await userEvent.click(button); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("is reachable and operable from the keyboard", async () => { + const onClear = vi.fn(); + render(); + await userEvent.tab(); + expect( + screen.getByRole("button", { name: CLEAR_DRAFT_LABEL }), + ).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("is disabled with the composer", async () => { + const onClear = vi.fn(); + render(); + const button = screen.getByRole("button", { name: CLEAR_DRAFT_LABEL }); + expect(button).toBeDisabled(); + await userEvent.click(button); + expect(onClear).not.toHaveBeenCalled(); + }); +}); diff --git a/studio/src/app/workspace/_components/clear-draft-button.tsx b/studio/src/app/workspace/_components/clear-draft-button.tsx new file mode 100644 index 0000000000..a8aac7b2ca --- /dev/null +++ b/studio/src/app/workspace/_components/clear-draft-button.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { keycaps } from "@/lib/shortcuts/registry"; +import { cn } from "@/lib/utils"; +import { CLEAR_DRAFT_COMBO } from "./composer-keys"; + +/** The accessible name of the composer's × button. */ +export const CLEAR_DRAFT_LABEL = "Clear draft"; + +/** The hover hint: the label plus the chord that does the same thing. */ +export const CLEAR_DRAFT_TITLE = `${CLEAR_DRAFT_LABEL} (${keycaps(CLEAR_DRAFT_COMBO).join("")})`; + +/** + * The composer's one-shot draft clear as a button (the TUI's ctrl+u + * `ClearPrompt`, for the mouse and for touch). Renders only while there is + * something to clear — text, a `[Pasted text #N]` chip or a staged file — + * so an empty composer never shows a dead control. Idle or streaming alike: + * it empties the field and never touches the run (Esc's cancel arm is the + * only key that does). The same `onClear` backs ⌘⇧U and the double-Esc guard. + */ +export function ClearDraftButton({ + hasDraft, + disabled = false, + onClear, + className, +}: { + /** Text, paste chips or attachments are staged. */ + hasDraft: boolean; + /** The composer is read-only (a disabled surface). */ + disabled?: boolean; + /** Empties the draft (and typically refocuses the field). */ + onClear: () => void; + className?: string; +}) { + if (!hasDraft) return null; + return ( + + ); +} diff --git a/studio/src/app/workspace/_components/composer-draft-guards.test.tsx b/studio/src/app/workspace/_components/composer-draft-guards.test.tsx new file mode 100644 index 0000000000..1e18f02754 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-draft-guards.test.tsx @@ -0,0 +1,324 @@ +import { act, renderHook } from "@testing-library/react"; +import type { Editor } from "@tiptap/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ESCAPE_ARM_MS } from "@/app/workspace/chat/_components/escape-layering"; +import { draftStorageKey } from "@/lib/draft-store"; +import { memoryStorage } from "@/test/memory-storage"; +import { + DRAFT_WRITE_DEBOUNCE_MS, + useComposerDraftGuards, +} from "./composer-draft-guards"; + +/** + * The composer's draft guards over a fake editor (driving TipTap keydowns + * in jsdom is impractical; the hook reads the plain-text mirror ChatInput + * keeps in sync, so a `setText` here stands in for a keystroke): the + * double-Esc arm machine, sessionStorage persistence with its restore / + * flush points, and the has-text signal the surfaces lift. + */ + +/** A minimal Editor: a doc that is a plain string. */ +function fakeEditor(initial = "") { + let doc = initial; + const editor = { + get isEmpty() { + return doc === ""; + }, + getText: () => doc, + commands: { + setContent: (content: { content: { content: { text: string }[] }[] }) => { + doc = content.content + .map((p) => p.content.map((t) => t.text).join("")) + .join("\n"); + return true; + }, + clearContent: () => { + doc = ""; + return true; + }, + }, + }; + return editor as unknown as Editor & { readonly isEmpty: boolean }; +} + +interface HarnessProps { + editor: Editor | null; + escapePress?: number; + draftKey?: string; + initialTextPending?: boolean; + onDraftChange?: (hasText: boolean) => void; + clearComposer?: () => void; +} + +/** ChatInput's shape around the hook: the `text` mirror plus its setter. */ +function useHarness(props: HarnessProps) { + const [text, setText] = useState(""); + const guards = useComposerDraftGuards({ + editor: props.editor, + text, + setText, + escapePress: props.escapePress, + draftKey: props.draftKey, + initialTextPending: props.initialTextPending ?? false, + onDraftChange: props.onDraftChange, + clearComposer: () => { + props.clearComposer?.(); + props.editor?.commands.clearContent(); + setText(""); + }, + }); + return { text, setText, ...guards }; +} + +const KEY = draftStorageKey("s1"); + +describe("useComposerDraftGuards", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("sessionStorage", memoryStorage()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("draft persistence", () => { + it("restores a stored draft into an empty editor once it mounts", () => { + sessionStorage.setItem(KEY, "left over\nfrom before"); + const editor = fakeEditor(); + const { result, rerender } = renderHook(useHarness, { + // The editor is null on the first render (immediatelyRender: false). + initialProps: { editor: null, draftKey: "s1" } as HarnessProps, + }); + expect(result.current.text).toBe(""); + rerender({ editor, draftKey: "s1" }); + expect(result.current.text).toBe("left over\nfrom before"); + expect(editor.getText()).toBe("left over\nfrom before"); + }); + + it("never restores over a pending seed, and not into a non-empty editor", () => { + sessionStorage.setItem(KEY, "stored"); + const seeded = renderHook(useHarness, { + initialProps: { + editor: fakeEditor(), + draftKey: "s1", + initialTextPending: true, + } as HarnessProps, + }); + expect(seeded.result.current.text).toBe(""); + + const typed = renderHook(useHarness, { + initialProps: { + editor: fakeEditor("already typing"), + draftKey: "s1", + } as HarnessProps, + }); + expect(typed.result.current.text).toBe(""); + }); + + it("leaves a stored draft alone when the composer mounts empty and idle", () => { + // The initial (empty) text must not be persisted as a removal before + // the restore reads the entry — a mount is not an edit. + sessionStorage.setItem(KEY, "stored"); + renderHook(useHarness, { + initialProps: { editor: null, draftKey: "s1" } as HarnessProps, + }); + expect(sessionStorage.getItem(KEY)).toBe("stored"); + }); + + it("writes an edit after the debounce and removes the entry at once when the composer empties", () => { + const { result } = renderHook(useHarness, { + initialProps: { editor: fakeEditor(), draftKey: "s1" } as HarnessProps, + }); + act(() => result.current.setText("hel")); + act(() => result.current.setText("hello")); + expect(sessionStorage.getItem(KEY)).toBeNull(); + act(() => vi.advanceTimersByTime(DRAFT_WRITE_DEBOUNCE_MS)); + expect(sessionStorage.getItem(KEY)).toBe("hello"); + + act(() => result.current.setText("")); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); + + it("does not persist without a draftKey (the thread panel)", () => { + const { result } = renderHook(useHarness, { + initialProps: { editor: fakeEditor() } as HarnessProps, + }); + act(() => result.current.setText("ephemeral")); + act(() => vi.advanceTimersByTime(DRAFT_WRITE_DEBOUNCE_MS)); + expect(sessionStorage.length).toBe(0); + }); + + it("flushes a pending write on pagehide and on unmount", () => { + const first = renderHook(useHarness, { + initialProps: { editor: fakeEditor(), draftKey: "s1" } as HarnessProps, + }); + act(() => first.result.current.setText("not yet written")); + expect(sessionStorage.getItem(KEY)).toBeNull(); + act(() => { + window.dispatchEvent(new Event("pagehide")); + }); + expect(sessionStorage.getItem(KEY)).toBe("not yet written"); + first.unmount(); + + sessionStorage.clear(); + const second = renderHook(useHarness, { + initialProps: { editor: fakeEditor(), draftKey: "s1" } as HarnessProps, + }); + act(() => second.result.current.setText("leaving")); + second.unmount(); + expect(sessionStorage.getItem(KEY)).toBe("leaving"); + }); + + it("flushes the previous key when the draftKey changes and keeps the in-memory text (no restore over it)", () => { + sessionStorage.setItem(draftStorageKey("s2"), "s2 draft"); + const editor = fakeEditor(); + const { result, rerender } = renderHook(useHarness, { + initialProps: { editor, draftKey: "s1" } as HarnessProps, + }); + // Typing lands in the editor doc; ChatInput mirrors it into `text`. + editor.commands.setContent({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "s1 draft" }] }, + ], + }); + act(() => result.current.setText("s1 draft")); + // Switching chats before the debounce fired: s1's text lands under s1. + rerender({ editor, draftKey: "s2" }); + expect(sessionStorage.getItem(KEY)).toBe("s1 draft"); + // The composer still shows s1's text (the in-memory carry-over), so s2's + // stored draft is neither restored over it nor touched. + expect(result.current.text).toBe("s1 draft"); + expect(sessionStorage.getItem(draftStorageKey("s2"))).toBe("s2 draft"); + }); + + it("restores the new key's draft into an EMPTY composer on a key change, without deleting anything on the way", () => { + sessionStorage.setItem(draftStorageKey("s2"), "s2 draft"); + const editor = fakeEditor(); + const { result, rerender } = renderHook(useHarness, { + initialProps: { editor, draftKey: "s1" } as HarnessProps, + }); + expect(result.current.text).toBe(""); + rerender({ editor, draftKey: "s2" }); + expect(result.current.text).toBe("s2 draft"); + // A blank composer flushes nothing: switching away from a never-typed + // key leaves storage alone. + expect(sessionStorage.getItem(KEY)).toBeNull(); + expect(sessionStorage.getItem(draftStorageKey("s2"))).toBe("s2 draft"); + }); + }); + + describe("has-text signal", () => { + it("fires only when the has-text state flips, and false on unmount", () => { + const onDraftChange = vi.fn(); + const { result, unmount } = renderHook(useHarness, { + initialProps: { editor: fakeEditor(), onDraftChange } as HarnessProps, + }); + expect(onDraftChange).not.toHaveBeenCalled(); + act(() => result.current.setText("a")); + act(() => result.current.setText("ab")); + expect(onDraftChange).toHaveBeenCalledTimes(1); + expect(onDraftChange).toHaveBeenLastCalledWith(true); + act(() => result.current.setText(" ")); + expect(onDraftChange).toHaveBeenCalledTimes(2); + expect(onDraftChange).toHaveBeenLastCalledWith(false); + act(() => result.current.setText("back")); + expect(onDraftChange).toHaveBeenCalledTimes(3); + unmount(); + expect(onDraftChange).toHaveBeenCalledTimes(4); + expect(onDraftChange).toHaveBeenLastCalledWith(false); + }); + }); + + describe("double-Esc clear", () => { + it("arms on the first press, clears on a second inside the window, and drops the stored draft", () => { + const clearComposer = vi.fn(); + const editor = fakeEditor(); + const { result, rerender } = renderHook(useHarness, { + initialProps: { + editor, + draftKey: "s1", + escapePress: 0, + clearComposer, + } as HarnessProps, + }); + act(() => result.current.setText("draft")); + act(() => vi.advanceTimersByTime(DRAFT_WRITE_DEBOUNCE_MS)); + expect(sessionStorage.getItem(KEY)).toBe("draft"); + + rerender({ editor, draftKey: "s1", escapePress: 1, clearComposer }); + expect(result.current.escapeArmed).toBe(true); + expect(clearComposer).not.toHaveBeenCalled(); + expect(result.current.text).toBe("draft"); + + act(() => vi.advanceTimersByTime(ESCAPE_ARM_MS - 1)); + rerender({ editor, draftKey: "s1", escapePress: 2, clearComposer }); + expect(clearComposer).toHaveBeenCalledTimes(1); + expect(result.current.text).toBe(""); + expect(result.current.escapeArmed).toBe(false); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); + + it("re-arms instead of clearing once the window has lapsed", () => { + const clearComposer = vi.fn(); + const editor = fakeEditor(); + const { result, rerender } = renderHook(useHarness, { + initialProps: { + editor, + escapePress: 0, + clearComposer, + } as HarnessProps, + }); + act(() => result.current.setText("draft")); + rerender({ editor, escapePress: 1, clearComposer }); + expect(result.current.escapeArmed).toBe(true); + act(() => vi.advanceTimersByTime(ESCAPE_ARM_MS)); + // The hint has gone; the next press is a first press again. + expect(result.current.escapeArmed).toBe(false); + rerender({ editor, escapePress: 2, clearComposer }); + expect(clearComposer).not.toHaveBeenCalled(); + expect(result.current.escapeArmed).toBe(true); + expect(result.current.text).toBe("draft"); + }); + + it("disarms on an edit, and ignores presses on an empty composer", () => { + const clearComposer = vi.fn(); + const editor = fakeEditor(); + const { result, rerender } = renderHook(useHarness, { + initialProps: { + editor, + escapePress: 0, + clearComposer, + } as HarnessProps, + }); + // Empty: nothing to clear, nothing arms. + rerender({ editor, escapePress: 1, clearComposer }); + expect(result.current.escapeArmed).toBe(false); + + act(() => result.current.setText("draft")); + rerender({ editor, escapePress: 2, clearComposer }); + expect(result.current.escapeArmed).toBe(true); + // Typing disarms: the following press is a first press. + act(() => result.current.setText("draft!")); + expect(result.current.escapeArmed).toBe(false); + rerender({ editor, escapePress: 3, clearComposer }); + expect(clearComposer).not.toHaveBeenCalled(); + expect(result.current.escapeArmed).toBe(true); + }); + + it("ignores the counter's initial value on mount", () => { + const clearComposer = vi.fn(); + const { result } = renderHook(useHarness, { + initialProps: { + editor: fakeEditor("typed"), + escapePress: 7, + clearComposer, + } as HarnessProps, + }); + expect(result.current.escapeArmed).toBe(false); + expect(clearComposer).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/studio/src/app/workspace/_components/composer-draft-guards.ts b/studio/src/app/workspace/_components/composer-draft-guards.ts new file mode 100644 index 0000000000..3bcf7484c2 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-draft-guards.ts @@ -0,0 +1,201 @@ +"use client"; + +import type { Editor } from "@tiptap/react"; +import { type RefObject, useEffect, useRef, useState } from "react"; +import { + DISARMED_ESCAPE, + ESCAPE_ARM_MS, + type EscapeArm, + pressEscapeToClear, +} from "@/app/workspace/chat/_components/escape-layering"; +import { + clearDraft, + draftStorageKey, + readDraft, + writeDraft, +} from "@/lib/draft-store"; +import { setComposerText } from "./composer-mentions"; + +/** How long an edit sits before it is persisted (a keystroke burst writes once). */ +export const DRAFT_WRITE_DEBOUNCE_MS = 300; + +/** The armed hint under the field after the first Esc. */ +export const ESCAPE_ARMED_HINT = "Press Esc again to clear the draft"; + +export interface ComposerDraftGuardsInput { + editor: Editor | null; + /** The composer's plain-text mirror of the editor (every edit flows + through it, so it is the one signal this hook needs). */ + text: string; + /** Updates that mirror after this hook rewrites the editor (a restore). */ + setText: (text: string) => void; + /** The forwarded double-Esc counter (`useComposerEscape`); each change is + one Esc press that nothing else claimed. Absent = no Esc arm. */ + escapePress?: number; + /** The draft's persistence identity (a session id, or `"new"` for the + draft view); absent = the draft is not persisted (the thread panel). */ + draftKey?: string; + /** A seed (`initialText`) is about to land: never restore over it. */ + initialTextPending: boolean; + /** Fires when "the composer holds text" flips, so the surface can arm the + leave guard and the Esc layering; also fires `false` on unmount. */ + onDraftChange?: (hasText: boolean) => void; + /** Empties the composer (editor content, text mirror, staged files). */ + clearComposer: () => void; +} + +type TimerRef = RefObject | null>; + +function clearTimer(ref: TimerRef) { + if (ref.current !== null) { + clearTimeout(ref.current); + ref.current = null; + } +} + +/** + * The composer's draft guards, three arms over one text mirror: + * + * 1. Double-Esc clear — each forwarded `escapePress` runs the pure arm + * machine (`pressEscapeToClear`): the first press arms (a hint shows + * under the field), a second within `ESCAPE_ARM_MS` empties the composer, + * any edit disarms. An empty composer ignores the press. + * 2. Draft persistence — under `draftKey`, the text is written to + * sessionStorage (debounced; a blank composer removes the entry at once), + * flushed on `pagehide`, on unmount and when the key changes, and + * restored into an EMPTY editor once it mounts. A pending seed wins over + * a stored draft. + * 3. `onDraftChange` — the has-text signal the surface lifts into the + * `beforeunload` guard and the Esc layering. + */ +export function useComposerDraftGuards(input: ComposerDraftGuardsInput): { + escapeArmed: boolean; +} { + const { + editor, + text, + setText, + escapePress, + draftKey, + initialTextPending, + onDraftChange, + clearComposer, + } = input; + const [escapeArmed, setEscapeArmed] = useState(false); + + const textRef = useRef(text); + const prevTextRef = useRef(text); + const keyRef = useRef(null); + const writeTimerRef = useRef | null>(null); + const armRef = useRef(DISARMED_ESCAPE); + const armTimerRef = useRef | null>(null); + const lastEscapePressRef = useRef(escapePress); + const hadTextRef = useRef(false); + const onDraftChangeRef = useRef(onDraftChange); + const clearComposerRef = useRef(clearComposer); + + // Latest callbacks, read from effects and the pagehide listener (kept off + // the render phase for the React Compiler). + useEffect(() => { + onDraftChangeRef.current = onDraftChange; + clearComposerRef.current = clearComposer; + }); + + // Every edit: the has-text signal, disarm a pending Esc, persist. + useEffect(() => { + textRef.current = text; + const hasText = text.trim() !== ""; + if (hasText !== hadTextRef.current) { + hadTextRef.current = hasText; + onDraftChangeRef.current?.(hasText); + } + // The initial run (and a StrictMode replay) carries no edit: persisting + // here would delete a stored draft before the restore below reads it. + if (prevTextRef.current === text) return; + prevTextRef.current = text; + if (armRef.current.armedUntil > 0) { + armRef.current = DISARMED_ESCAPE; + clearTimer(armTimerRef); + setEscapeArmed(false); + } + const key = keyRef.current; + if (!key) return; + clearTimer(writeTimerRef); + if (!hasText) { + clearDraft(key); + return; + } + writeTimerRef.current = setTimeout(() => { + writeTimerRef.current = null; + if (keyRef.current === key) writeDraft(key, textRef.current); + }, DRAFT_WRITE_DEBOUNCE_MS); + }, [text]); + + // The persistence identity: a key change flushes the previous draft, and + // an empty editor takes the stored draft for the new key (unless a seed is + // about to land, which wins). + useEffect(() => { + const nextKey = draftKey ? draftStorageKey(draftKey) : null; + const prevKey = keyRef.current; + if (prevKey && prevKey !== nextKey) { + // Flush what is typed under the key it was typed under. A blank + // composer has nothing to flush — an edit that emptied it already + // removed the entry, and a never-edited blank must not delete a draft + // the user has not seen. + clearTimer(writeTimerRef); + if (textRef.current.trim() !== "") writeDraft(prevKey, textRef.current); + } + keyRef.current = nextKey; + if (!editor || !nextKey || initialTextPending || !editor.isEmpty) return; + const stored = readDraft(nextKey); + if (!stored) return; + setComposerText(editor, stored); + setText(editor.getText({ blockSeparator: "\n" })); + }, [editor, draftKey, initialTextPending, setText]); + + // One forwarded Esc: arm, or clear inside the window. + useEffect(() => { + if (escapePress === undefined || escapePress === lastEscapePressRef.current) + return; + lastEscapePressRef.current = escapePress; + if (textRef.current.trim() === "") return; + const { state, clear } = pressEscapeToClear(armRef.current, Date.now()); + armRef.current = state; + clearTimer(armTimerRef); + if (clear) { + clearComposerRef.current(); + if (keyRef.current) clearDraft(keyRef.current); + setEscapeArmed(false); + return; + } + armTimerRef.current = setTimeout(() => { + armTimerRef.current = null; + armRef.current = DISARMED_ESCAPE; + setEscapeArmed(false); + }, ESCAPE_ARM_MS); + setEscapeArmed(true); + }, [escapePress]); + + // Flush a pending write when the page hides and when the composer leaves; + // the surface's has-text signal drops with it (the draft is stored, so a + // leave no longer loses it). + useEffect(() => { + const flush = () => { + clearTimer(writeTimerRef); + if (keyRef.current && textRef.current.trim() !== "") + writeDraft(keyRef.current, textRef.current); + }; + window.addEventListener("pagehide", flush); + return () => { + window.removeEventListener("pagehide", flush); + flush(); + clearTimer(armTimerRef); + if (hadTextRef.current) { + hadTextRef.current = false; + onDraftChangeRef.current?.(false); + } + }; + }, []); + + return { escapeArmed }; +} diff --git a/studio/src/app/workspace/_components/composer-file-mention.test.ts b/studio/src/app/workspace/_components/composer-file-mention.test.ts new file mode 100644 index 0000000000..2386013c92 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-file-mention.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + ATTACH_FILE_MENU_ID, + fileMenuRows, + isAttachFileItem, + isFileMenuItem, + isPathLikeQuery, + pathMentionText, +} from "./composer-file-mention"; + +/** + * The `@` menu's file rows (TUI file-mention parity, web-shaped): an + * "Attach a file…" row while the query could still spell "file", a + * "Mention path" row for a path-shaped token, neither for an agent query. + */ +describe("fileMenuRows", () => { + it("offers Attach a file… for an empty query and every prefix of 'file'", () => { + for (const query of ["", "f", "fi", "FIL", "file"]) { + const rows = fileMenuRows(query); + expect(rows[0]).toMatchObject({ + id: ATTACH_FILE_MENU_ID, + primary: "Attach a file…", + }); + expect(rows).toHaveLength(1); + } + }); + + it("offers Mention path for a path-shaped token, inserting the query verbatim", () => { + for (const query of ["src/", "main.go", "./x", "~/notes", "a/b.c"]) { + const rows = fileMenuRows(query); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + label: `@${query}`, + primary: `Mention path @${query}`, + }); + expect(pathMentionText(rows[0])).toBe(`@${query}`); + } + }); + + it("offers nothing for an agent-shaped query", () => { + expect(fileMenuRows("rev")).toEqual([]); + expect(fileMenuRows("planner")).toEqual([]); + }); + + it("can offer both when a path token still spells 'file' (e.g. 'file.')", () => { + const rows = fileMenuRows("fi"); + expect(rows.map((row) => isAttachFileItem(row))).toEqual([true]); + const both = fileMenuRows("file."); + expect(both.map((row) => isFileMenuItem(row))).toEqual([true]); + expect(pathMentionText(both[0])).toBe("@file."); + }); +}); + +describe("isPathLikeQuery", () => { + it("needs a slash or a dot and no whitespace", () => { + expect(isPathLikeQuery("src/")).toBe(true); + expect(isPathLikeQuery("x.ts")).toBe(true); + expect(isPathLikeQuery("rev")).toBe(false); + expect(isPathLikeQuery("")).toBe(false); + expect(isPathLikeQuery("a b.c")).toBe(false); + }); +}); + +describe("row predicates", () => { + it("recognise the two file rows and nothing else", () => { + const agent = { id: "reviewer" }; + expect(isAttachFileItem({ id: ATTACH_FILE_MENU_ID })).toBe(true); + expect(isAttachFileItem(agent)).toBe(false); + expect(pathMentionText(agent)).toBeNull(); + expect(isFileMenuItem(agent)).toBe(false); + expect(isFileMenuItem({ id: ATTACH_FILE_MENU_ID })).toBe(true); + expect(isFileMenuItem({ id: "__path:src/" })).toBe(true); + }); +}); diff --git a/studio/src/app/workspace/_components/composer-file-mention.ts b/studio/src/app/workspace/_components/composer-file-mention.ts new file mode 100644 index 0000000000..d5c4df0846 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-file-mention.ts @@ -0,0 +1,73 @@ +/** + * The file rows of the composer's `@` menu (the TUI's `@`-mention file menu, + * web-shaped). Two synthetic rows ride the agent menu's item list: + * + * - "Attach a file…" — opens the native picker for a CLIENT-LOCAL file + * (images/audio become media parts, everything else is inlined as text). + * Shown for an empty query or a prefix of "file", so `@f` / `@fil` reach it. + * - "Mention path @" — for a path-shaped token (`src/`, `main.go`, + * `./x`, `~/y`): inserts the plain text `@` so the model reads the + * path and can `Read` it. No completion is claimed — the daemon exposes no + * file-listing route, so Studio never pretends to know the tree. + * + * Neither row inserts a chip; the composer's select handler recognises them + * by id and acts instead. Both are `ComposerMenuItem`s so the existing menu + * renders them with no new kind. + */ + +import type { ComposerMenuItem } from "./composer-mentions"; + +/** The sentinel id of the Attach-a-file row. */ +export const ATTACH_FILE_MENU_ID = "__attach-file"; +const PATH_MENTION_PREFIX = "__path:"; + +/** The attach row is offered while the query could still be typing "file". */ +function offersAttachRow(query: string): boolean { + return "file".startsWith(query.toLowerCase()); +} + +/** A token with a slash or a dot reads as a path; whitespace never does. */ +export function isPathLikeQuery(query: string): boolean { + return query.length > 0 && !/\s/.test(query) && /[/.]/.test(query); +} + +/** The rows to PREPEND to the agent matches for this query. */ +export function fileMenuRows(query: string): ComposerMenuItem[] { + const rows: ComposerMenuItem[] = []; + if (offersAttachRow(query)) { + rows.push({ + id: ATTACH_FILE_MENU_ID, + label: "file", + primary: "Attach a file…", + secondary: + "pick a local file to send with this message (images and audio as media, anything else as text)", + }); + } + if (isPathLikeQuery(query)) { + rows.push({ + id: `${PATH_MENTION_PREFIX}${query}`, + label: `@${query}`, + primary: `Mention path @${query}`, + secondary: + "inserts the path as text — the agent can read it from the workspace", + }); + } + return rows; +} + +export function isAttachFileItem(item: Pick): boolean { + return item.id === ATTACH_FILE_MENU_ID; +} + +/** The plain text a path row inserts (`@src/main.go`), or null for any other row. */ +export function pathMentionText( + item: Pick, +): string | null { + if (!item.id.startsWith(PATH_MENTION_PREFIX)) return null; + return `@${item.id.slice(PATH_MENTION_PREFIX.length)}`; +} + +/** True for either file row (the menu draws a paperclip, not the agent glyph). */ +export function isFileMenuItem(item: Pick): boolean { + return isAttachFileItem(item) || pathMentionText(item) !== null; +} diff --git a/studio/src/app/workspace/_components/composer-frame.test.ts b/studio/src/app/workspace/_components/composer-frame.test.ts new file mode 100644 index 0000000000..6a67edd6bb --- /dev/null +++ b/studio/src/app/workspace/_components/composer-frame.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { + COMPOSER_DEFAULT_MIN_ROWS, + COMPOSER_MAX_ROWS, + COMPOSER_MIN_ROWS_VAR, + composerEditorStyle, + composerFrameClass, + composerMinRows, +} from "./composer-frame"; + +/** + * The composer box's pure layout decisions: which border it wears (one state + * wins at a time — drag-over > window drag > streaming with a draft > mode + * tint > plain) and how many rows it rests at (`compact` → 1, an explicit + * `rows` clamped to the scroll cap, else the TUI's three). + */ + +const idle = { + isDragOver: false, + isWindowDrag: false, + isStreaming: false, + hasText: false, +}; + +describe("composerFrameClass", () => { + it("wears the plain border for Manual mode and for a surface with no Mode selector", () => { + expect(composerFrameClass({ ...idle, mode: "default" })).toBe( + "border-zinc-300 dark:border-zinc-700", + ); + expect(composerFrameClass(idle)).toBe( + "border-zinc-300 dark:border-zinc-700", + ); + }); + + it("tints the box by mode on the TUI's mapping: Plan → info, Accept edits → success", () => { + const plan = composerFrameClass({ ...idle, mode: "plan" }); + expect(plan).toContain("border-info"); + expect(plan).not.toContain("border-zinc"); + const edits = composerFrameClass({ ...idle, mode: "acceptEdits" }); + expect(edits).toContain("border-success"); + expect(edits).not.toContain("border-zinc"); + }); + + it("a draft held while a run streams beats the mode tint (warning)", () => { + const cls = composerFrameClass({ + ...idle, + mode: "plan", + isStreaming: true, + hasText: true, + }); + expect(cls).toContain("border-warning"); + expect(cls).not.toContain("border-info"); + }); + + it("an empty composer during a stream keeps the mode tint (nothing to steer or queue)", () => { + expect( + composerFrameClass({ ...idle, mode: "plan", isStreaming: true }), + ).toContain("border-info"); + }); + + it("a drag anywhere in the window beats streaming", () => { + const cls = composerFrameClass({ + ...idle, + mode: "acceptEdits", + isStreaming: true, + hasText: true, + isWindowDrag: true, + }); + expect(cls).toContain("border-brand/50"); + expect(cls).not.toContain("border-warning"); + expect(cls).not.toContain("border-success"); + }); + + it("a file dragged over the box beats everything (the strongest brand frame)", () => { + const cls = composerFrameClass({ + mode: "acceptEdits", + isDragOver: true, + isWindowDrag: true, + isStreaming: true, + hasText: true, + }); + expect(cls).toContain("border-brand "); + expect(cls).toContain("ring-2"); + expect(cls).not.toContain("border-brand/50"); + expect(cls).not.toContain("border-warning"); + expect(cls).not.toContain("border-success"); + }); + + it("yields exactly one border class, so states never blend", () => { + const states = [ + { ...idle, mode: "plan" as const }, + { ...idle, mode: "acceptEdits" as const }, + { ...idle, mode: "plan" as const, isStreaming: true, hasText: true }, + { ...idle, isWindowDrag: true }, + { ...idle, isDragOver: true }, + idle, + ]; + for (const state of states) { + const borders = composerFrameClass(state) + .split(/\s+/) + .filter((c) => /^border-(?!zinc)/.test(c) || c === "border-zinc-300"); + expect(borders, JSON.stringify(state)).toHaveLength(1); + } + }); +}); + +describe("composerMinRows", () => { + it("rests at the TUI's three rows when the caller asks for nothing", () => { + expect(COMPOSER_DEFAULT_MIN_ROWS).toBe(3); + expect(composerMinRows({})).toBe(3); + expect(composerMinRows({ compact: false })).toBe(3); + }); + + it("a compact (side-panel) composer is one row, whatever rows says", () => { + expect(composerMinRows({ compact: true })).toBe(1); + expect(composerMinRows({ compact: true, rows: 6 })).toBe(1); + }); + + it("honours an explicit rows, clamped to [1, the eight-row scroll cap]", () => { + expect(COMPOSER_MAX_ROWS).toBe(8); + expect(composerMinRows({ rows: 1 })).toBe(1); + expect(composerMinRows({ rows: 5 })).toBe(5); + expect(composerMinRows({ rows: 5.7 })).toBe(5); + expect(composerMinRows({ rows: 0 })).toBe(1); + expect(composerMinRows({ rows: -2 })).toBe(1); + expect(composerMinRows({ rows: 20 })).toBe(COMPOSER_MAX_ROWS); + expect(composerMinRows({ rows: Number.NaN })).toBe(3); + }); +}); + +describe("composerEditorStyle", () => { + it("sets no inline style when the stylesheet default should stand alone", () => { + expect(composerEditorStyle({})).toBeUndefined(); + expect(composerEditorStyle({ compact: false })).toBeUndefined(); + }); + + it("carries the row count as the custom property the stylesheet reads", () => { + expect(COMPOSER_MIN_ROWS_VAR).toBe("--composer-min-rows"); + expect(composerEditorStyle({ rows: 5 })).toEqual({ + "--composer-min-rows": 5, + }); + expect(composerEditorStyle({ compact: true })).toEqual({ + "--composer-min-rows": 1, + }); + expect(composerEditorStyle({ rows: 40 })).toEqual({ + "--composer-min-rows": COMPOSER_MAX_ROWS, + }); + }); +}); diff --git a/studio/src/app/workspace/_components/composer-frame.ts b/studio/src/app/workspace/_components/composer-frame.ts new file mode 100644 index 0000000000..0c00ce51cd --- /dev/null +++ b/studio/src/app/workspace/_components/composer-frame.ts @@ -0,0 +1,87 @@ +import type { CSSProperties } from "react"; +import { modeComposerRingClass } from "@/lib/permission-mode"; +import type { SessionPermissionMode } from "@/lib/protocol"; + +/** + * The composer box's two layout decisions, extracted pure (like + * `resolveComposerAction`) so the precedence table and the row policy are + * testable without mounting TipTap: + * + * - `composerFrameClass` — which border/ring the frame wears. One state wins + * at a time, in this order: a file dragged OVER the box (brand, strongest — + * the drop target must read as such), a drag anywhere in the window + * (brand/50, "you can drop here"), a queued draft while a run streams + * (warning — the text will steer or queue, not send), the permission-mode + * tint (the TUI recolours its input by mode: Plan → info, Accept edits → + * success; Manual gets none), then the plain border. + * - `composerEditorStyle` — the resting height. The TUI's input box rests at + * three rows and grows to eight before it scrolls inside itself; the + * stylesheet (`.composer-editor .ProseMirror`, globals.css) carries that + * default and the eight-row cap, and a caller's `rows` / `compact` reach it + * through the `--composer-min-rows` custom property set on the editor's + * wrapper. Mobile (≤499px) pins one row in the stylesheet itself, on the + * editor element, so it wins over any wrapper value. + */ + +/** Rows the composer rests at when a caller sets neither `rows` nor `compact`. */ +export const COMPOSER_DEFAULT_MIN_ROWS = 3; + +/** Rows the composer grows to before it scrolls inside itself. */ +export const COMPOSER_MAX_ROWS = 8; + +/** The custom property the stylesheet reads for the resting row count. */ +export const COMPOSER_MIN_ROWS_VAR = "--composer-min-rows"; + +const PLAIN_FRAME = "border-zinc-300 dark:border-zinc-700"; + +export function composerFrameClass(input: { + /** The mode as SHOWN (a held switch over the confirmed one); omit it on a + surface with no Mode selector, which never tints. */ + mode?: SessionPermissionMode; + isDragOver: boolean; + isWindowDrag: boolean; + isStreaming: boolean; + hasText: boolean; +}): string { + if (input.isDragOver) { + return "border-brand bg-brand/5 dark:bg-brand/10 ring-2 ring-brand/20"; + } + if (input.isWindowDrag) return "border-brand/50 ring-1 ring-brand/10"; + if (input.isStreaming && input.hasText) { + return "border-warning shadow-warning/10"; + } + const ring = input.mode ? modeComposerRingClass(input.mode) : ""; + return ring || PLAIN_FRAME; +} + +/** + * The resting row count for a composer: `compact` (a side-panel composer) + * is one row; an explicit `rows` is clamped to [1, COMPOSER_MAX_ROWS] so a + * minimum can never exceed the scroll cap; otherwise the TUI's three. + */ +export function composerMinRows(input: { + rows?: number; + compact?: boolean; +}): number { + if (input.compact) return 1; + const rows = input.rows; + if (rows === undefined || !Number.isFinite(rows)) { + return COMPOSER_DEFAULT_MIN_ROWS; + } + return Math.min(COMPOSER_MAX_ROWS, Math.max(1, Math.floor(rows))); +} + +/** + * The inline style for the editor's wrapper: the `--composer-min-rows` + * custom property when a caller asked for something, undefined otherwise so + * the stylesheet's default stands alone (no inline style to override). + */ +export function composerEditorStyle(input: { + rows?: number; + compact?: boolean; +}): CSSProperties | undefined { + if (input.rows === undefined && !input.compact) return undefined; + return { + [COMPOSER_MIN_ROWS_VAR]: composerMinRows(input), + } as CSSProperties; +} diff --git a/studio/src/app/workspace/_components/composer-insert.test.ts b/studio/src/app/workspace/_components/composer-insert.test.ts new file mode 100644 index 0000000000..5393ab55b4 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-insert.test.ts @@ -0,0 +1,60 @@ +import { Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vitest"; +import { appendComposerText, composerParagraphs } from "./composer-insert"; +import { composerText, setComposerText } from "./composer-mentions"; + +/** + * The pickers' insertion path: text lands AFTER the existing draft, separated + * by a blank line, line breaks become paragraphs, and an empty composer takes + * the text as its whole draft. Nothing is sent — the helper only edits the + * document. + */ + +let editor: Editor | null = null; + +function newEditor(): Editor { + editor = new Editor({ extensions: [StarterKit] }); + return editor; +} + +afterEach(() => { + editor?.destroy(); + editor = null; +}); + +describe("composerParagraphs", () => { + it("makes one paragraph per line, empty for a blank line", () => { + expect(composerParagraphs("a\n\nb")).toEqual([ + { type: "paragraph", content: [{ type: "text", text: "a" }] }, + { type: "paragraph", content: [] }, + { type: "paragraph", content: [{ type: "text", text: "b" }] }, + ]); + }); +}); + +describe("appendComposerText", () => { + it("fills an empty composer with the text, line breaks as paragraphs", () => { + const ed = newEditor(); + appendComposerText(ed, "user: Summarize README.md\n\nassistant: Reading."); + expect(composerText(ed)).toBe( + "user: Summarize README.md\n\nassistant: Reading.", + ); + }); + + it("appends after an existing draft, separated by a blank line, keeping the draft", () => { + const ed = newEditor(); + setComposerText(ed, "Please look at this:"); + appendComposerText(ed, "Fixture notes: line one\nline two"); + expect(composerText(ed)).toBe( + "Please look at this:\n\nFixture notes: line one\nline two", + ); + }); + + it("appends twice in order", () => { + const ed = newEditor(); + appendComposerText(ed, "first"); + appendComposerText(ed, "second"); + expect(composerText(ed)).toBe("first\n\nsecond"); + }); +}); diff --git a/studio/src/app/workspace/_components/composer-insert.ts b/studio/src/app/workspace/_components/composer-insert.ts new file mode 100644 index 0000000000..7cc4156299 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-insert.ts @@ -0,0 +1,46 @@ +import type { Editor, JSONContent } from "@tiptap/react"; + +/** + * Appending text to the composer WITHOUT replacing what the user already + * typed — the insertion path behind the MCP prompt and resource pickers. The + * TUI rewrites its whole input (`insertIntoInput` → `prompt.Rewrite`); the + * web composer keeps the draft and adds the text after it, separated by a + * blank line, so a half-written message is never lost to a picker. The + * inserted text is REVIEWED, never sent: nothing here touches the send path. + */ + +/** + * The paragraph nodes for plain text — one per line, an empty paragraph for + * a blank line (the shape `setComposerText` builds, so an inserted block + * reads back byte-identically through `composerText`). + */ +export function composerParagraphs(text: string): JSONContent[] { + return text.split("\n").map((line) => ({ + type: "paragraph", + content: line ? [{ type: "text", text: line }] : [], + })); +} + +/** + * Appends `text` to the composer: an empty composer takes it as the whole + * draft; a non-empty one gets a blank paragraph then the text after its + * current content. The caret lands at the end, ready for the user to edit + * and press Enter. + */ +export function appendComposerText(editor: Editor, text: string): void { + const paragraphs = composerParagraphs(text); + if (editor.isEmpty) { + editor + .chain() + .setContent({ type: "doc", content: paragraphs }) + .focus("end") + .run(); + return; + } + const end = editor.state.doc.content.size; + editor + .chain() + .insertContentAt(end, [{ type: "paragraph", content: [] }, ...paragraphs]) + .focus("end") + .run(); +} diff --git a/studio/src/app/workspace/_components/composer-keys.test.ts b/studio/src/app/workspace/_components/composer-keys.test.ts new file mode 100644 index 0000000000..ec88a21ed8 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-keys.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { RESERVED_COMBOS } from "@/lib/shortcuts/keymap"; +import { keycaps } from "@/lib/shortcuts/registry"; +import { + ALWAYS_NEWLINE_COMBO, + CLEAR_DRAFT_COMBO, + isAlwaysNewlineChord, + isClearDraftChord, +} from "./composer-keys"; + +/** A minimal KeyboardEvent-like object for the chord recognisers. */ +function ev( + key: string, + mods: Partial<{ + meta: boolean; + ctrl: boolean; + shift: boolean; + alt: boolean; + }> = {}, +) { + return { + key, + metaKey: mods.meta ?? false, + ctrlKey: mods.ctrl ?? false, + shiftKey: mods.shift ?? false, + altKey: mods.alt ?? false, + }; +} + +/** + * The clear-draft chord (the TUI's ctrl+u): ⌘⇧U / Ctrl+Shift+U and only + * that. The recogniser rides `matchCombo`, so a plain Backspace, ⌘⌫, a bare + * U or an alt-laden variant never clear the field. + */ +describe("isClearDraftChord", () => { + it("is ⌘⇧U / Ctrl+Shift+U, a chord no browser reserves", () => { + expect(CLEAR_DRAFT_COMBO).toBe("mod+shift+u"); + // NOT ⌘⇧⌫: on macOS the delete key reports as Backspace, so that chord is + // Chrome's/Firefox's Clear browsing data — which the keymap reserves. + expect(RESERVED_COMBOS.has("mod+shift+delete")).toBe(true); + expect(RESERVED_COMBOS.has(CLEAR_DRAFT_COMBO)).toBe(false); + expect(keycaps(CLEAR_DRAFT_COMBO)).toEqual(["⌘", "⇧", "U"]); + }); + + it("matches on either modifier, with shift, however the key is cased", () => { + expect(isClearDraftChord(ev("U", { meta: true, shift: true }))).toBe(true); + expect(isClearDraftChord(ev("u", { meta: true, shift: true }))).toBe(true); + expect(isClearDraftChord(ev("U", { ctrl: true, shift: true }))).toBe(true); + }); + + it("rejects plain Backspace, ⌘⌫, ⌘⇧⌫, a bare U, ⌘U and an alt variant", () => { + expect(isClearDraftChord(ev("Backspace"))).toBe(false); + expect(isClearDraftChord(ev("Backspace", { meta: true }))).toBe(false); + expect( + isClearDraftChord(ev("Backspace", { meta: true, shift: true })), + ).toBe(false); + expect(isClearDraftChord(ev("u"))).toBe(false); + expect(isClearDraftChord(ev("U", { shift: true }))).toBe(false); + expect(isClearDraftChord(ev("u", { meta: true }))).toBe(false); + expect( + isClearDraftChord(ev("U", { meta: true, shift: true, alt: true })), + ).toBe(false); + }); +}); + +/** + * The unconditional newline (the TUI's ctrl+j): any Enter with ⌘ or Ctrl + * held is left to the editor's Mod-Enter hardBreak — never intercepted as a + * send, queue or steer. Plain Enter and Shift+Enter keep their own rules. + */ +describe("isAlwaysNewlineChord", () => { + it("is documented as mod+enter", () => { + expect(ALWAYS_NEWLINE_COMBO).toBe("mod+enter"); + expect(keycaps(ALWAYS_NEWLINE_COMBO)).toEqual(["⌘", "Enter"]); + }); + + it("recognises ⌘Enter and Ctrl+Enter, with or without shift or alt", () => { + expect(isAlwaysNewlineChord(ev("Enter", { meta: true }))).toBe(true); + expect(isAlwaysNewlineChord(ev("Enter", { ctrl: true }))).toBe(true); + expect(isAlwaysNewlineChord(ev("Enter", { meta: true, shift: true }))).toBe( + true, + ); + expect(isAlwaysNewlineChord(ev("Enter", { ctrl: true, alt: true }))).toBe( + true, + ); + }); + + it("leaves plain Enter, Shift+Enter and non-Enter chords to the other rules", () => { + expect(isAlwaysNewlineChord(ev("Enter"))).toBe(false); + expect(isAlwaysNewlineChord(ev("Enter", { shift: true }))).toBe(false); + expect(isAlwaysNewlineChord(ev("Enter", { alt: true }))).toBe(false); + expect(isAlwaysNewlineChord(ev("u", { meta: true }))).toBe(false); + expect(isAlwaysNewlineChord(ev("Escape", { meta: true }))).toBe(false); + }); +}); diff --git a/studio/src/app/workspace/_components/composer-keys.ts b/studio/src/app/workspace/_components/composer-keys.ts new file mode 100644 index 0000000000..4d3ab6d931 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-keys.ts @@ -0,0 +1,45 @@ +import { type ComboKeyEvent, matchCombo } from "@/lib/shortcuts/registry"; + +/** + * The composer's two chord recognisers, extracted pure so the editor's + * capture-phase keydown handler stays a dispatch table and the chords are + * testable without driving TipTap. Both are OWNED by the composer's own + * listener (never the global dispatcher): they fire only while the caret is + * in the field, and their registry rows are documentation-only (`fixed`). + */ + +/** + * The one-shot draft clear (the TUI's ctrl+u `ClearPrompt`): ⌘⇧U on macOS, + * Ctrl+Shift+U elsewhere. NOT ⌘⇧⌫ — on macOS the delete key reports as + * `Backspace`, so that chord is Chrome's and Firefox's "Clear browsing data" + * (⌘⇧Delete, which the keymap already lists as browser-reserved). ⌘⇧U is + * unbound in Chrome, Firefox and Safari; Edge's Read aloud yields to a page + * handler that prevents it. The × button beside Send and the double-Esc + * guard are the same clear, so the chord is a convenience, not the only path. + */ +export const CLEAR_DRAFT_COMBO = "mod+shift+u"; + +/** + * The unconditional newline (the TUI's ctrl+j): ⌘Enter / Ctrl+Enter always + * inserts a line break — idle or streaming, empty field or not — where + * Shift+Enter is repurposed mid-run as the opposite of the Enter preference. + */ +export const ALWAYS_NEWLINE_COMBO = "mod+enter"; + +/** True for exactly the clear-draft chord (`matchCombo` grammar: mod + shift + * + U, no alt). A plain Backspace, ⌘⌫ or a bare U never match. */ +export function isClearDraftChord(event: ComboKeyEvent): boolean { + return matchCombo(CLEAR_DRAFT_COMBO, event); +} + +/** + * True when Enter is pressed with ⌘ or Ctrl held — with or without shift or + * alt. The composer's keydown must NOT intercept such a press: it falls + * through to the editor, whose own `Mod-Enter` hardBreak binding inserts the + * line break (a shift/alt variant matches no editor binding and is a no-op, + * never a send). Kept wider than `matchCombo("mod+enter")` on purpose — a + * modifier-laden Enter must never route into send/queue/steer. + */ +export function isAlwaysNewlineChord(event: ComboKeyEvent): boolean { + return event.key === "Enter" && (event.metaKey || event.ctrlKey); +} diff --git a/studio/src/app/workspace/_components/composer-layout.test.ts b/studio/src/app/workspace/_components/composer-layout.test.ts new file mode 100644 index 0000000000..8e33f4a134 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-layout.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + COMPOSER_DEFAULT_MIN_ROWS, + COMPOSER_MAX_ROWS, + COMPOSER_MIN_ROWS_VAR, +} from "./composer-frame"; + +/** + * The composer's growth policy lives in the stylesheet, not in React: the + * TipTap editor rests at three rows, grows with content to eight, then + * scrolls inside itself; the docked mobile composer pins one row. jsdom lays + * nothing out, so this reads `globals.css` mechanically and checks the rule + * says what `composer-frame.ts` (the wrapper's custom property) assumes. + */ + +const css = readFileSync( + resolve(import.meta.dirname, "../../globals.css"), + "utf8", +); + +/** The declarations of the first `selector { … }` block after `from`. */ +function ruleBody(selector: string, from = 0): string { + const at = css.indexOf(`${selector} {`, from); + if (at < 0) throw new Error(`no rule for ${selector}`); + const open = css.indexOf("{", at); + const close = css.indexOf("}", open); + return css.slice(open + 1, close); +} + +/** Every `@media (max-width: 499px) { … }` block, braces balanced. */ +function mobileBlocks(): string[] { + const blocks: string[] = []; + const needle = "@media (max-width: 499px) {"; + let from = 0; + for (;;) { + const at = css.indexOf(needle, from); + if (at < 0) return blocks; + let depth = 0; + let i = css.indexOf("{", at); + for (; i < css.length; i++) { + if (css[i] === "{") depth++; + else if (css[i] === "}" && --depth === 0) break; + } + blocks.push(css.slice(at, i + 1)); + from = i + 1; + } +} + +describe(".composer-editor .ProseMirror growth policy", () => { + const body = ruleBody(".composer-editor .ProseMirror"); + + it("rests at the default row count read from the wrapper's custom property", () => { + expect(body).toContain( + `min-height: calc(var(${COMPOSER_MIN_ROWS_VAR}, ${COMPOSER_DEFAULT_MIN_ROWS}) * 1.5rem)`, + ); + }); + + it("caps growth at eight rows and scrolls inside itself past that", () => { + expect(body).toContain(`max-height: calc(${COMPOSER_MAX_ROWS} * 1.5rem)`); + expect(body).toContain("overflow-y: auto"); + }); + + it("maps rows 1:1 onto the line-height the calc multiplies", () => { + expect(body).toContain("line-height: 1.5rem"); + }); +}); + +describe("the docked mobile composer", () => { + it("pins one resting row on the editor element itself, inside the ≤499px media block", () => { + const pin = mobileBlocks().find((block) => + block.includes(".composer-editor .ProseMirror"), + ); + expect( + pin, + "a mobile media block scoping the composer editor", + ).toBeDefined(); + expect(pin).toContain(`${COMPOSER_MIN_ROWS_VAR}: 1`); + }); +}); diff --git a/studio/src/app/workspace/_components/composer-mentions.ts b/studio/src/app/workspace/_components/composer-mentions.ts new file mode 100644 index 0000000000..d96593bd84 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-mentions.ts @@ -0,0 +1,114 @@ +import Mention from "@tiptap/extension-mention"; +import { PluginKey } from "@tiptap/pm/state"; +import type { Editor } from "@tiptap/react"; +import type { SuggestionOptions } from "@tiptap/suggestion"; + +/** + * Atomic `@agent` / `/skill` chips for the TipTap composer. + * + * Each is a Mention node (an atom, so Backspace removes the whole chip in one + * press) rendered as the same green "connected" pill the old overlay painted. + * `renderText` serializes a chip back to `@handle` / `/name`, so `editor.getText` + * reconstructs the exact plain-text message the model receives. + * + * Autocomplete is delegated to the caller: the caller supplies the filtered + * items per keystroke and a `render` that drives its own menu UI (we reuse the + * existing full-width popover rather than TipTap's default), keeping the + * on-screen behaviour identical to the pre-TipTap composer. + */ + +/** One row in the autocomplete menu (and the data an inserted chip carries). */ +export interface ComposerMenuItem { + /** Text inserted after the trigger char — the agent handle or command name. */ + readonly id: string; + /** Chip label (same as `id`; the trigger char is added by `renderText`). */ + readonly label: string; + /** Primary text shown in the menu row. */ + readonly primary: string; + /** Muted description shown in the menu row. */ + readonly secondary: string; + /** A Studio built-in slash command (rendered with a terminal glyph and + * run on selection, never inserted as a chip). */ + readonly builtin?: boolean; +} + +/** The trigger kind, so the caller can render agent vs command rows. */ +export type ComposerMentionKind = "agent" | "command"; + +/** `render` factory: given the kind, return a TipTap suggestion renderer. */ +type MakeRender = ( + kind: ComposerMentionKind, +) => SuggestionOptions["render"]; + +const CHIP_CLASS = + "rounded px-1 py-0.5 bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"; + +function mentionNode( + name: string, + char: string, + pluginKeyName: string, + items: (query: string) => ComposerMenuItem[], + makeRender: MakeRender, + kind: ComposerMentionKind, + allow?: SuggestionOptions["allow"], +) { + return Mention.extend({ name }).configure({ + HTMLAttributes: { class: CHIP_CLASS, "data-composer-mention": name }, + // `id` is what renderText appends after the trigger char. + renderText: ({ node }) => `${char}${node.attrs.label ?? node.attrs.id}`, + suggestion: { + char, + pluginKey: new PluginKey(pluginKeyName), + items: ({ query }) => items(query), + allow, + render: makeRender(kind), + }, + }); +} + +/** + * Build the two mention extensions. `agentItems` / `commandItems` filter the + * live lists for the current query; `makeRender` wires each suggestion to the + * caller's menu. Commands only trigger at the very start of the message + * (`range.from === 1`), matching the old "slash menu only when the message is a + * single /token" rule; agents trigger anywhere after whitespace. + */ +export function createComposerMentions(opts: { + agentItems: (query: string) => ComposerMenuItem[]; + commandItems: (query: string) => ComposerMenuItem[]; + makeRender: MakeRender; +}) { + return [ + mentionNode( + "agentMention", + "@", + "composerAgentMention", + opts.agentItems, + opts.makeRender, + "agent", + ), + mentionNode( + "commandMention", + "/", + "composerCommandMention", + opts.commandItems, + opts.makeRender, + "command", + ({ range }) => range.from === 1, + ), + ]; +} + +/** Serialize the editor to the plain-text message (chips → `@handle`/`/name`). */ +export function composerText(editor: Editor): string { + return editor.getText({ blockSeparator: "\n" }).trim(); +} + +/** Replace the whole document with plain text (voice input, quoted replies). */ +export function setComposerText(editor: Editor, text: string) { + const content = text.split("\n").map((line) => ({ + type: "paragraph", + content: line ? [{ type: "text", text: line }] : [], + })); + editor.commands.setContent({ type: "doc", content }); +} diff --git a/studio/src/app/workspace/_components/composer-paste.test.ts b/studio/src/app/workspace/_components/composer-paste.test.ts new file mode 100644 index 0000000000..39c06e71e1 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-paste.test.ts @@ -0,0 +1,356 @@ +import { Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { composerText } from "./composer-mentions"; +import { + applyComposerPaste, + classifyPaste, + PASTE_CHAR_THRESHOLD, + PASTE_LINE_THRESHOLD, + type PasteDataTransfer, + PastePlaceholder, + pasteDecision, + pasteLineCount, + pasteMarker, + pasteTitle, + readPasteClipboard, + visibleComposerLength, +} from "./composer-paste"; + +const png = (name = "shot.png") => + new File([new Uint8Array([137, 80, 78, 71])], name, { type: "image/png" }); + +const lines = (n: number) => + Array.from({ length: n }, (_, i) => `line ${i + 1}`).join("\n"); + +describe("classifyPaste", () => { + it("stages at the character threshold, alone", () => { + expect( + classifyPaste({ + text: "x".repeat(PASTE_CHAR_THRESHOLD - 1), + existingLength: 0, + }), + ).toBe("inline"); + expect( + classifyPaste({ + text: "x".repeat(PASTE_CHAR_THRESHOLD), + existingLength: 0, + }), + ).toBe("stage"); + }); + + it("counts the text already in the composer (cumulative)", () => { + expect(classifyPaste({ text: "x".repeat(1500), existingLength: 600 })).toBe( + "stage", + ); + expect(classifyPaste({ text: "x".repeat(1500), existingLength: 400 })).toBe( + "inline", + ); + }); + + it("stages at the line threshold whatever the length", () => { + expect( + classifyPaste({ + text: lines(PASTE_LINE_THRESHOLD - 1), + existingLength: 0, + }), + ).toBe("inline"); + expect( + classifyPaste({ text: lines(PASTE_LINE_THRESHOLD), existingLength: 0 }), + ).toBe("stage"); + }); + + it("never stages an empty paste, even on a full composer", () => { + expect(classifyPaste({ text: "", existingLength: 5000 })).toBe("inline"); + }); +}); + +describe("pasteLineCount", () => { + it("counts newline-separated lines; a trailing newline adds none", () => { + expect(pasteLineCount("")).toBe(0); + expect(pasteLineCount("one")).toBe(1); + expect(pasteLineCount("one\ntwo")).toBe(2); + expect(pasteLineCount("one\ntwo\n")).toBe(2); + }); +}); + +describe("pasteMarker / pasteTitle", () => { + it("labels the N-th paste", () => { + expect(pasteMarker(3)).toBe("[Pasted text #3]"); + }); + + it("describes size and the first line, cutting a long one", () => { + expect(pasteTitle("hello\nworld")).toBe("11 characters, 2 lines: hello"); + expect(pasteTitle("x")).toBe("1 characters, 1 line: x"); + const long = "a".repeat(100); + expect(pasteTitle(long)).toBe(`100 characters, 1 line: ${"a".repeat(80)}…`); + expect(pasteTitle("")).toBe("0 characters, 0 lines"); + }); +}); + +describe("readPasteClipboard", () => { + const dt = (over: Partial): PasteDataTransfer => ({ + files: [], + items: [], + getData: () => "", + ...over, + }); + + it("reads files and the text/plain flavour, folding CRLF", () => { + const file = png(); + const clip = readPasteClipboard( + dt({ + files: [file], + getData: (f) => (f === "text/plain" ? "a\r\nb\rc" : ""), + }), + ); + expect(clip.files).toEqual([file]); + expect(clip.text).toBe("a\nb\nc"); + }); + + it("falls back to file items when `files` is empty", () => { + const file = png("item.png"); + const clip = readPasteClipboard( + dt({ + items: [ + { kind: "string", getAsFile: () => null }, + { kind: "file", getAsFile: () => file }, + ], + }), + ); + expect(clip.files).toEqual([file]); + }); + + it("is empty for a missing DataTransfer", () => { + expect(readPasteClipboard(null)).toEqual({ files: [], text: "" }); + }); +}); + +describe("pasteDecision", () => { + it("attaches when files arrive with no text (a screenshot)", () => { + expect(pasteDecision({ files: [png()], text: "" }, 0)).toBe("attach"); + expect(pasteDecision({ files: [png()], text: " \n" }, 0)).toBe("attach"); + }); + + it("prefers text over a rendition image (Excel, Word)", () => { + expect(pasteDecision({ files: [png()], text: "A1\tB1" }, 0)).toBe( + "default", + ); + expect( + pasteDecision( + { files: [png()], text: "x".repeat(PASTE_CHAR_THRESHOLD) }, + 0, + ), + ).toBe("stage"); + }); + + it("stages a large text, defaults a small one or nothing", () => { + expect( + pasteDecision({ files: [], text: "x".repeat(PASTE_CHAR_THRESHOLD) }, 0), + ).toBe("stage"); + expect(pasteDecision({ files: [], text: "x".repeat(1500) }, 600)).toBe( + "stage", + ); + expect(pasteDecision({ files: [], text: "hello" }, 0)).toBe("default"); + expect(pasteDecision({ files: [], text: "" }, 0)).toBe("default"); + }); +}); + +/** + * The chip inside a real TipTap editor (jsdom): the same StarterKit shape the + * composer uses plus the placeholder node, so `composerText` expansion, the + * DOM marker, deletion and the HTML round trip are proven against the actual + * schema rather than a stub. + */ +describe("PastePlaceholder in an editor", () => { + let editor: Editor | null = null; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + const make = () => { + editor = new Editor({ + extensions: [ + StarterKit.configure({ + heading: false, + bold: false, + italic: false, + strike: false, + code: false, + codeBlock: false, + blockquote: false, + bulletList: false, + orderedList: false, + listItem: false, + horizontalRule: false, + link: false, + underline: false, + }), + PastePlaceholder, + ], + content: "", + }); + return editor; + }; + + const counter = () => { + let n = 0; + return () => ++n; + }; + + it("stages a large paste as a chip whose composerText is the full payload", () => { + const ed = make(); + const stageFiles = vi.fn(); + const payload = lines(PASTE_LINE_THRESHOLD); + const outcome = applyComposerPaste( + ed, + { files: [], text: payload }, + { stageFiles, nextNumber: counter() }, + ); + expect(outcome).toBe("stage"); + expect(stageFiles).not.toHaveBeenCalled(); + // Send/steer/queue read this: the chip expands in place. + expect(composerText(ed)).toBe(payload); + // The DOM shows the marker, not the payload. + const html = ed.getHTML(); + expect(html).toContain('data-paste-placeholder="1"'); + expect(html).toContain("[Pasted text #1]"); + expect(ed.view.dom.textContent).toBe("[Pasted text #1]"); + expect( + ed.view.dom.querySelector("span[data-paste-placeholder]"), + ).toHaveAttribute( + "title", + expect.stringContaining(`${PASTE_LINE_THRESHOLD} lines: line 1`), + ); + // The user sees 16 characters, not the payload. + expect(visibleComposerLength(ed)).toBe(pasteMarker(1).length); + }); + + it("numbers chips from the caller and expands them in document order", () => { + const ed = make(); + const next = counter(); + const stageFiles = vi.fn(); + applyComposerPaste( + ed, + { files: [], text: "x".repeat(PASTE_CHAR_THRESHOLD) }, + { stageFiles, nextNumber: next }, + ); + ed.commands.insertContent(" and then "); + applyComposerPaste( + ed, + { files: [], text: "y".repeat(PASTE_CHAR_THRESHOLD) }, + { stageFiles, nextNumber: next }, + ); + expect(ed.view.dom.textContent).toBe( + "[Pasted text #1] and then [Pasted text #2]", + ); + expect(composerText(ed)).toBe( + `${"x".repeat(PASTE_CHAR_THRESHOLD)} and then ${"y".repeat(PASTE_CHAR_THRESHOLD)}`, + ); + }); + + it("measures the cumulative threshold on the visible text, not the payload", () => { + const ed = make(); + const stageFiles = vi.fn(); + const next = counter(); + applyComposerPaste( + ed, + { files: [], text: "x".repeat(5000) }, + { stageFiles, nextNumber: next }, + ); + // A small follow-up paste is NOT staged just because a chip is present… + expect( + applyComposerPaste( + ed, + { files: [], text: "a url" }, + { stageFiles, nextNumber: next }, + ), + ).toBe("default"); + // …but typed text does count towards the next paste. + ed.commands.insertContent("t".repeat(600)); + expect( + applyComposerPaste( + ed, + { files: [], text: "z".repeat(1500) }, + { stageFiles, nextNumber: next }, + ), + ).toBe("stage"); + expect(ed.view.dom.textContent).toBe( + `[Pasted text #1]${"t".repeat(600)}[Pasted text #2]`, + ); + }); + + it("replaces the active selection with the chip", () => { + const ed = make(); + ed.commands.setContent("

keep DROP keep

"); + // Select "DROP" (positions are 1-based inside the paragraph). + ed.commands.setTextSelection({ from: 6, to: 10 }); + applyComposerPaste( + ed, + { files: [], text: lines(PASTE_LINE_THRESHOLD) }, + { stageFiles: vi.fn(), nextNumber: counter() }, + ); + expect(ed.view.dom.textContent).toBe("keep [Pasted text #1] keep"); + expect(composerText(ed)).toBe(`keep ${lines(PASTE_LINE_THRESHOLD)} keep`); + }); + + it("drops the whole paste when the chip is deleted, and on clearContent", () => { + const ed = make(); + applyComposerPaste( + ed, + { files: [], text: "x".repeat(PASTE_CHAR_THRESHOLD) }, + { stageFiles: vi.fn(), nextNumber: counter() }, + ); + // The chip is one atom: a single delete of its range removes the paste. + ed.commands.deleteRange({ from: 1, to: 2 }); + expect(composerText(ed)).toBe(""); + applyComposerPaste( + ed, + { files: [], text: "x".repeat(PASTE_CHAR_THRESHOLD) }, + { stageFiles: vi.fn(), nextNumber: counter() }, + ); + ed.commands.clearContent(); + expect(composerText(ed)).toBe(""); + expect(ed.isEmpty).toBe(true); + }); + + it("round-trips through HTML so an in-editor cut/paste keeps the payload", () => { + const ed = make(); + ed.commands.setContent( + '

before [Pasted text #4]

', + ); + expect(composerText(ed)).toBe("before hello\nworld"); + expect(ed.view.dom.textContent).toBe("before [Pasted text #4]"); + }); + + it("hands clipboard files to the staging gate and leaves the document alone", () => { + const ed = make(); + ed.commands.setContent("

draft

"); + const stageFiles = vi.fn(); + const file = png(); + expect( + applyComposerPaste( + ed, + { files: [file], text: "" }, + { stageFiles, nextNumber: counter() }, + ), + ).toBe("attach"); + expect(stageFiles).toHaveBeenCalledWith([file]); + expect(composerText(ed)).toBe("draft"); + }); + + it("leaves a small text paste to the editor's default", () => { + const ed = make(); + const stageFiles = vi.fn(); + expect( + applyComposerPaste( + ed, + { files: [], text: "just a line" }, + { stageFiles, nextNumber: counter() }, + ), + ).toBe("default"); + expect(stageFiles).not.toHaveBeenCalled(); + expect(ed.isEmpty).toBe(true); + }); +}); diff --git a/studio/src/app/workspace/_components/composer-paste.ts b/studio/src/app/workspace/_components/composer-paste.ts new file mode 100644 index 0000000000..9702f990e1 --- /dev/null +++ b/studio/src/app/workspace/_components/composer-paste.ts @@ -0,0 +1,260 @@ +import { + type Editor, + mergeAttributes, + Node as TiptapNode, +} from "@tiptap/react"; + +/** + * Paste handling for the TipTap composer — the web half of the TUI's `ctrl+v` + * (docs/tui.md "ctrl+v — paste a clipboard image" and "Large text pastes"). + * + * Three outcomes, decided once per paste event by `pasteDecision`: + * + * - **attach** — the clipboard carries files and no text (a screenshot, an + * image copied from a page or an editor, a file copied in the OS): the files + * go through the composer's staging gate (`stageFiles`, the same path the + * "+" picker and drag-and-drop use, so the session's modality/size limits + * apply). Files that arrive WITH text are a rendition of that text (Excel, + * Numbers and Word all add a PNG of the copied cells or paragraphs), so the + * text wins and no image is attached behind the user's back. + * - **stage** — a LARGE text paste (`classifyPaste`) does not enter the + * document literally. It becomes one atomic `[Pasted text #N]` chip + * (`PastePlaceholder`) whose `renderText` is the full payload, so + * `composerText` — the one serializer send, steer and queue read — expands + * it in place with no second send path. Backspace removes the whole chip + * (and with it that paste), exactly like a mention chip. + * - **default** — everything else is left to ProseMirror's own paste, so a + * small paste stays byte-identical to the literal-insert behaviour. + * + * The TUI's pasted-media-PATH staging and its wl-paste/xclip/pbpaste tool + * chain are terminal mechanics with no browser analogue: the paste event + * hands the browser the image bytes directly. + */ + +/** A paste of at least this many characters — alone, or together with the + * text already in the composer — is staged behind a placeholder. */ +export const PASTE_CHAR_THRESHOLD = 2000; +/** A paste of at least this many lines is staged, whatever its length. */ +export const PASTE_LINE_THRESHOLD = 30; + +/** The placeholder node's schema name. */ +const PASTE_PLACEHOLDER_NAME = "pastePlaceholder"; + +/** The chip's look: the mention chip's shape (composer-mentions.ts + * `CHIP_CLASS`) in a distinct muted tint, so a staged paste is never + * mistaken for an `@agent` chip. */ +const PASTE_CHIP_CLASS = + "rounded px-1 py-0.5 bg-sky-500/15 text-sky-800 dark:text-sky-300"; + +/** Longest first line shown in the chip's tooltip before it is cut. */ +const TITLE_PREVIEW_CHARS = 80; + +export type PasteClass = "inline" | "stage"; + +/** Number of lines in a paste: `\n`-separated, a trailing newline adds none. */ +export function pasteLineCount(text: string): number { + if (text === "") return 0; + const body = text.endsWith("\n") ? text.slice(0, -1) : text; + return body.split("\n").length; +} + +/** + * Whether a text paste is inserted literally or staged. Staging happens at + * `PASTE_CHAR_THRESHOLD` characters — counting the text already in the + * composer too, so repeated medium pastes cannot rebuild a huge draft (only + * the incoming paste is ever staged; typed text never converts) — or at + * `PASTE_LINE_THRESHOLD` lines regardless of length. + */ +export function classifyPaste({ + text, + existingLength, +}: { + text: string; + existingLength: number; +}): PasteClass { + if (text.length === 0) return "inline"; + if (existingLength + text.length >= PASTE_CHAR_THRESHOLD) return "stage"; + if (pasteLineCount(text) >= PASTE_LINE_THRESHOLD) return "stage"; + return "inline"; +} + +/** The visible chip label for the N-th staged paste of a draft. */ +export function pasteMarker(n: number): string { + return `[Pasted text #${n}]`; +} + +/** The chip's tooltip: size, then the first line (cut when long). */ +export function pasteTitle(text: string): string { + const lines = pasteLineCount(text); + const firstLine = text.split("\n", 1)[0] ?? ""; + const preview = + firstLine.length > TITLE_PREVIEW_CHARS + ? `${firstLine.slice(0, TITLE_PREVIEW_CHARS)}…` + : firstLine; + const size = `${text.length.toLocaleString("en-US")} characters, ${lines} ${lines === 1 ? "line" : "lines"}`; + return preview ? `${size}: ${preview}` : size; +} + +/** What one paste event carries, read once from its DataTransfer. */ +export interface PasteClipboard { + readonly files: readonly File[]; + /** The `text/plain` half, line endings normalised to `\n`. */ + readonly text: string; +} + +/** The subset of DataTransfer the reader touches (duck-typed for tests). */ +export interface PasteDataTransfer { + readonly files?: ArrayLike | null; + readonly items?: ArrayLike<{ + readonly kind: string; + getAsFile(): File | null; + }> | null; + getData(format: string): string; +} + +/** + * Read the paste's files and plain text. Files come from `files` and, where a + * browser only reports them as items (Safari has), from the `kind: "file"` + * items; text is the `text/plain` flavour with CRLF folded to `\n`. + */ +export function readPasteClipboard( + data: PasteDataTransfer | null | undefined, +): PasteClipboard { + if (!data) return { files: [], text: "" }; + let files: File[] = data.files ? Array.from(data.files) : []; + if (files.length === 0 && data.items) { + for (const item of Array.from(data.items)) { + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (file) files.push(file); + } + } + files = files.filter((f): f is File => f != null); + const text = data.getData("text/plain").replace(/\r\n?/g, "\n"); + return { files, text }; +} + +export type PasteDecision = "attach" | "stage" | "default"; + +/** + * Decide what one paste becomes: files with no text attach; large text is + * staged; anything else is left to the editor's default paste. See the module + * doc for why text beats an accompanying image. + */ +export function pasteDecision( + clip: PasteClipboard, + existingLength: number, +): PasteDecision { + const hasText = clip.text.trim().length > 0; + if (clip.files.length > 0 && !hasText) return "attach"; + if (hasText && classifyPaste({ text: clip.text, existingLength }) === "stage") + return "stage"; + return "default"; +} + +/** + * The staged-paste chip. Inline, atomic (Backspace removes the whole chip, + * the caret never enters it), selectable. `renderText` returns the payload, + * so `editor.getText` — and `composerText` on it — expands every chip to the + * pasted text in place; the DOM shows only the `[Pasted text #N]` marker and + * carries the payload as `data-paste-text`, so an in-editor cut/paste of a + * chip (or an undo) keeps its text. + */ +export const PastePlaceholder = TiptapNode.create({ + name: PASTE_PLACEHOLDER_NAME, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + n: { + default: 0, + parseHTML: (element: HTMLElement) => + Number(element.getAttribute("data-paste-placeholder")) || 0, + renderHTML: (attributes: Record) => ({ + "data-paste-placeholder": String(attributes.n), + }), + }, + text: { + default: "", + parseHTML: (element: HTMLElement) => + element.getAttribute("data-paste-text") ?? "", + renderHTML: (attributes: Record) => ({ + "data-paste-text": String(attributes.text), + }), + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-paste-placeholder]" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const text = String(node.attrs.text); + return [ + "span", + mergeAttributes(HTMLAttributes, { + class: PASTE_CHIP_CLASS, + title: pasteTitle(text), + contenteditable: "false", + }), + pasteMarker(Number(node.attrs.n)), + ]; + }, + + renderText({ node }) { + return String(node.attrs.text); + }, +}); + +/** + * The length of the composer as the USER sees it: chips count as their + * `[Pasted text #N]` marker, not their payload. This is the "current input" + * of the cumulative threshold — measured on the expanded text, every paste + * after a staged one would be staged too, however small. + */ +export function visibleComposerLength(editor: Editor): number { + let hidden = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === PASTE_PLACEHOLDER_NAME) { + hidden += + String(node.attrs.text).length - + pasteMarker(Number(node.attrs.n)).length; + } + return true; + }); + return editor.getText({ blockSeparator: "\n" }).length - hidden; +} + +/** + * Apply one paste to the composer and report what it became. `attach` hands + * the files to the caller's staging gate; `stage` replaces the selection (or + * inserts at the caret) with a chip numbered by `nextNumber`; `default` does + * nothing, so the caller lets the editor's own paste run. + */ +export function applyComposerPaste( + editor: Editor, + clip: PasteClipboard, + opts: { + stageFiles: (files: File[]) => void; + nextNumber: () => number; + }, +): PasteDecision { + const decision = pasteDecision(clip, visibleComposerLength(editor)); + if (decision === "attach") { + opts.stageFiles([...clip.files]); + } else if (decision === "stage") { + editor + .chain() + .insertContent({ + type: PASTE_PLACEHOLDER_NAME, + attrs: { n: opts.nextNumber(), text: clip.text }, + }) + .run(); + } + return decision; +} diff --git a/studio/src/app/workspace/_components/draft-model.test.ts b/studio/src/app/workspace/_components/draft-model.test.ts new file mode 100644 index 0000000000..0ec8c6155c --- /dev/null +++ b/studio/src/app/workspace/_components/draft-model.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + isDefaultOption, + liveModelProvenance, + modelProvenanceLabel, + resolveDraftModel, +} from "./draft-model"; + +const options = [ + { id: "", label: "Default model" }, + { id: "m1", label: "Model One", providerId: "p" }, + { id: "m2", label: "Model Two", providerId: "p" }, + { id: "shared", label: "Shared id", providerId: "other" }, +]; + +/** + * The one resolver the picker's label and the workspace's create call both + * use: an explicit pick wins, an untouched draft takes the Studio default + * only when the inventory lists it, and a vanished default is REPORTED (so + * the workspace can warn once) rather than silently degraded or rewritten. + */ +describe("resolveDraftModel", () => { + const studioDefault = { modelId: "m2", providerId: "p" }; + + it("an explicit pick wins over the Studio default", () => { + expect(resolveDraftModel("m1", studioDefault, options)).toEqual({ + id: "m1", + providerId: "p", + provenance: "pick", + staleDefault: false, + }); + }); + + it("an explicit auto pick is a pick for the daemon default, not the Studio default", () => { + expect(resolveDraftModel("", studioDefault, options)).toEqual({ + id: "", + provenance: "pick", + staleDefault: false, + }); + }); + + it("applies the Studio default to an untouched draft when the inventory lists it", () => { + expect(resolveDraftModel(null, studioDefault, options)).toEqual({ + id: "m2", + providerId: "p", + provenance: "studio-default", + staleDefault: false, + }); + }); + + it("matches the default by provider as well as id", () => { + expect( + resolveDraftModel(null, { modelId: "shared", providerId: "p" }, options), + ).toMatchObject({ + id: "", + provenance: "daemon-default", + staleDefault: true, + }); + }); + + it("flags a default the loaded inventory lacks and falls back to the daemon default", () => { + expect( + resolveDraftModel(null, { modelId: "gone", providerId: "p" }, options), + ).toEqual({ id: "", provenance: "daemon-default", staleDefault: true }); + }); + + it("does not call a default stale while the inventory is empty (still loading / offline)", () => { + expect(resolveDraftModel(null, studioDefault, [{ id: "" }])).toEqual({ + id: "", + provenance: "daemon-default", + staleDefault: false, + }); + expect(resolveDraftModel(null, studioDefault, [])).toEqual({ + id: "", + provenance: "daemon-default", + staleDefault: false, + }); + }); + + it("degrades a pick that left the inventory to the daemon default", () => { + expect(resolveDraftModel("gone", studioDefault, options)).toEqual({ + id: "", + provenance: "daemon-default", + staleDefault: false, + }); + }); + + it("untouched with no default is the daemon default", () => { + expect(resolveDraftModel(null, null, options)).toEqual({ + id: "", + provenance: "daemon-default", + staleDefault: false, + }); + }); +}); + +describe("liveModelProvenance", () => { + const studioDefault = { modelId: "m2", providerId: "p" }; + + it("reads an empty model as the daemon default", () => { + expect(liveModelProvenance("", studioDefault, options)).toBe( + "daemon-default", + ); + }); + + it("reads the Studio default's model as the Studio default", () => { + expect(liveModelProvenance("m2", studioDefault, options)).toBe( + "studio-default", + ); + // An unlisted id equal to the default is trusted by id. + expect(liveModelProvenance("m2", studioDefault, [])).toBe("studio-default"); + }); + + it("reads a same id on a different provider as a pick", () => { + expect( + liveModelProvenance( + "shared", + { modelId: "shared", providerId: "p" }, + options, + ), + ).toBe("pick"); + }); + + it("reads any other model as a pick", () => { + expect(liveModelProvenance("m1", studioDefault, options)).toBe("pick"); + expect(liveModelProvenance("m1", null, options)).toBe("pick"); + }); +}); + +describe("isDefaultOption / modelProvenanceLabel", () => { + it("never marks the auto row as the default", () => { + expect(isDefaultOption({ id: "" }, { modelId: "", providerId: "p" })).toBe( + false, + ); + }); + + it("labels the three provenances", () => { + expect(modelProvenanceLabel("pick")).toBe("your pick"); + expect(modelProvenanceLabel("studio-default")).toBe("Studio default"); + expect(modelProvenanceLabel("daemon-default")).toBe("daemon default"); + }); +}); diff --git a/studio/src/app/workspace/_components/draft-model.ts b/studio/src/app/workspace/_components/draft-model.ts new file mode 100644 index 0000000000..305314ba6f --- /dev/null +++ b/studio/src/app/workspace/_components/draft-model.ts @@ -0,0 +1,130 @@ +import type { DefaultModel } from "@/lib/model-preferences"; + +/** + * Where the model in force came from — the composer picker's "current:" + * header names it so a user can tell a deliberate pick from a default that + * applied on its own (the TUI's provenance tag). + * + * - `pick`: the user chose it in this draft (or the live chat was created + * with an explicit model that is not their Studio default). + * - `studio-default`: the browser-local default for new chats applied. + * - `daemon-default`: nothing picked and no applicable Studio default — the + * daemon uses its own default (or the router, when enabled). + */ +export type ModelProvenance = "pick" | "studio-default" | "daemon-default"; + +/** The short tag rendered next to the current model in the picker header. */ +export function modelProvenanceLabel(provenance: ModelProvenance): string { + switch (provenance) { + case "pick": + return "your pick"; + case "studio-default": + return "Studio default"; + default: + return "daemon default"; + } +} + +/** The subset of a composer option the resolver needs. */ +export interface DraftModelOption { + id: string; + providerId?: string; +} + +export interface ResolvedDraftModel { + /** The model id to send ("" = let the daemon pick). */ + id: string; + /** Present exactly when `id` is a listed model with a known provider. */ + providerId?: string; + provenance: ModelProvenance; + /** + * True when a Studio default exists but the loaded inventory (non-empty) + * does not list it — the caller warns ONCE and leaves the preference as it + * is; the chat falls back to the daemon default. + */ + staleDefault: boolean; +} + +/** True when `option` is the stored Studio default (provider + id). */ +export function isDefaultOption( + option: DraftModelOption, + studioDefault: DefaultModel | null, +): boolean { + return ( + studioDefault !== null && + option.id !== "" && + option.id === studioDefault.modelId && + (option.providerId ?? "") === studioDefault.providerId + ); +} + +/** + * Resolves what a NEW chat's create body should carry, in one place the + * picker (for its label and header) and the workspace (for the create call) + * both use so they can never disagree: + * + * 1. An explicit pick wins — `pick` is the picked id, or "" for the auto row + * (the user asked for the daemon default on purpose). A picked id that has + * since left the inventory degrades to the daemon default rather than + * sending a bare model_id the daemon would reject. + * 2. Untouched (`pick === null`): the Studio default applies when the + * inventory lists it (provider + id); a default the loaded, non-empty + * inventory lacks is reported `staleDefault` and NOT applied. + * 3. Otherwise the daemon default. + */ +export function resolveDraftModel( + pick: string | null, + studioDefault: DefaultModel | null, + options: readonly DraftModelOption[], +): ResolvedDraftModel { + if (pick !== null) { + if (pick === "") return { id: "", provenance: "pick", staleDefault: false }; + const option = options.find((m) => m.id === pick); + if (option?.providerId) { + return { + id: pick, + providerId: option.providerId, + provenance: "pick", + staleDefault: false, + }; + } + return { id: "", provenance: "daemon-default", staleDefault: false }; + } + if (studioDefault) { + const option = options.find((m) => isDefaultOption(m, studioDefault)); + if (option?.providerId) { + return { + id: option.id, + providerId: option.providerId, + provenance: "studio-default", + staleDefault: false, + }; + } + // Only a LOADED inventory can vouch that the default is gone; an empty + // list (still loading, daemon offline) is not evidence. + const loaded = options.some((m) => m.id !== ""); + return { id: "", provenance: "daemon-default", staleDefault: loaded }; + } + return { id: "", provenance: "daemon-default", staleDefault: false }; +} + +/** + * Provenance for a LIVE chat, whose model the daemon fixed at create: "" is + * the daemon default (an explicit auto pick is indistinguishable after the + * fact, and "daemon default" is what is in force either way); a model equal + * to the Studio default reads as that default; anything else was a pick. + */ +export function liveModelProvenance( + currentId: string, + studioDefault: DefaultModel | null, + options: readonly DraftModelOption[], +): ModelProvenance { + if (!currentId) return "daemon-default"; + if (!studioDefault || studioDefault.modelId !== currentId) return "pick"; + // The session row carries only the model id; match the provider through + // the listed option when there is one, else trust the id. + const option = options.find((m) => m.id === currentId); + return !option || isDefaultOption(option, studioDefault) + ? "studio-default" + : "pick"; +} diff --git a/studio/src/app/workspace/_components/effort-picker.test.tsx b/studio/src/app/workspace/_components/effort-picker.test.tsx new file mode 100644 index 0000000000..ade2689dfb --- /dev/null +++ b/studio/src/app/workspace/_components/effort-picker.test.tsx @@ -0,0 +1,152 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + EffortMenuItems, + EffortSheetSection, + effortTriggerLabel, + NO_REASONING_WARNING, +} from "./effort-picker"; + +/** + * The composer's Effort list: the daemon's neutral tiers with auto first, + * the checkmark on the tier in force (the draft's pick or the live + * session's resolved effort), picks reported as WIRE values ("" for auto), + * the live-chat fork note, and the TUI's no-reasoning warning. + */ + +const TIER_LABELS = ["Auto", "Low", "Medium", "High", "Extra high", "Max"]; + +function renderMenu(ui: React.ReactNode) { + return render( + + Model + {ui} + , + ); +} + +describe("EffortMenuItems (desktop submenu)", () => { + it("lists auto and the five daemon tiers in rank order", async () => { + renderMenu( {}} />); + const rows = await screen.findAllByRole("menuitemradio"); + expect(rows.map((r) => r.textContent)).toEqual(TIER_LABELS); + }); + + it("checkmarks the tier in force from the wire value", async () => { + renderMenu( {}} />); + expect( + await screen.findByRole("menuitemradio", { name: "Extra high" }), + ).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("menuitemradio", { name: "Auto" })).toHaveAttribute( + "aria-checked", + "false", + ); + }); + + it("reads the empty wire value as auto", async () => { + renderMenu( {}} />); + expect( + await screen.findByRole("menuitemradio", { name: "Auto" }), + ).toHaveAttribute("aria-checked", "true"); + }); + + it("reports picks as wire values: a tier verbatim, auto as the empty string", async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + renderMenu(); + await user.click( + await screen.findByRole("menuitemradio", { name: "High" }), + ); + expect(onPick).toHaveBeenLastCalledWith("high"); + }); + + it("reports auto as the empty wire value", async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + renderMenu(); + await user.click( + await screen.findByRole("menuitemradio", { name: "Auto" }), + ); + expect(onPick).toHaveBeenLastCalledWith(""); + }); + + it("says a live pick continues the chat in a copy, and stays quiet in a draft", async () => { + const { unmount } = renderMenu( + {}} live />, + ); + unmount(); + renderMenu( {}} />); + await screen.findAllByRole("menuitemradio"); + }); + + it("warns only when the model reports no reasoning support", async () => { + const { unmount } = renderMenu( + {}} modelReasoning={false} />, + ); + expect(await screen.findByRole("note")).toHaveTextContent( + NO_REASONING_WARNING, + ); + unmount(); + // Unknown (auto-routed / unlisted model): no warning on a guess. + renderMenu( {}} />); + await screen.findAllByRole("menuitemradio"); + expect(screen.queryByRole("note")).toBeNull(); + unmount(); + renderMenu( + {}} modelReasoning={true} />, + ); + await screen.findAllByRole("menuitemradio"); + expect(screen.queryByRole("note")).toBeNull(); + }); +}); + +describe("EffortSheetSection (mobile sheet)", () => { + it("renders the same tiers as a labelled group of toggle rows with the current one pressed", () => { + render( {}} />); + const group = screen.getByRole("group", { name: "Effort" }); + const rows = screen.getAllByRole("button"); + expect(group).toContainElement(rows[0]); + expect(rows.map((r) => r.textContent)).toEqual(TIER_LABELS); + expect(screen.getByRole("button", { name: "Max" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "Auto" })).toHaveAttribute( + "aria-pressed", + "false", + ); + }); + + it("reports the wire value of a tapped row and shows the live note and warning", async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + render( + , + ); + expect(screen.getByRole("note")).toHaveTextContent(NO_REASONING_WARNING); + await user.click(screen.getByRole("button", { name: "Low" })); + expect(onPick).toHaveBeenCalledWith("low"); + }); +}); + +describe("effortTriggerLabel", () => { + it("reads {model} · {effort} with auto for the empty wire value", () => { + expect(effortTriggerLabel("fixture-model", "medium")).toBe( + "fixture-model · Medium", + ); + expect(effortTriggerLabel("Default model", "")).toBe( + "Default model · Auto", + ); + }); +}); diff --git a/studio/src/app/workspace/_components/effort-picker.tsx b/studio/src/app/workspace/_components/effort-picker.tsx new file mode 100644 index 0000000000..364559df7a --- /dev/null +++ b/studio/src/app/workspace/_components/effort-picker.tsx @@ -0,0 +1,179 @@ +"use client"; + +/** + * The reasoning-effort half of the composer's model + effort picker: the + * daemon's neutral tiers (auto / low / medium / high / xhigh / max) as a + * checkmarked list, in the desktop dropdown's Effort submenu and the mobile + * sheet's Effort section alike. + * + * In a DRAFT the pick is pending local state that rides the first send's + * create body (`reasoning_effort`); in a LIVE chat the daemon fixes the tier + * at create, so a pick FORKS the chat onto the tier (the same handoff as a + * model switch) and the list says so. The checkmark follows the value the + * caller passes: the draft's pending pick, or the live session's + * `resolved_model.reasoning_effort` — the EFFECTIVE tier, never a guess. + * + * A model that reports no reasoning support gets the TUI's warning row: the + * daemon still accepts the tier, the provider may ignore it. + */ + +import { Check } from "lucide-react"; +import { + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@/components/ui/dropdown-menu"; +import { + EFFORT_TIERS, + effortLabel, + effortTier, + effortWire, +} from "@/lib/reasoning-effort"; +import { useShortcut } from "@/lib/shortcuts/use-shortcuts"; +import { cn } from "@/lib/utils"; + +/** + * Registers the `composer.model` shortcut (the TUI's F7 analogue) for the + * picker that mounts it. Rendered ONLY by a picker wired to a session, so a + * display-only composer (the thread panel) never claims the chord away from + * the main chat's picker — conditional mount is conditional registration. + */ +export function ModelPickerShortcut({ onOpen }: { onOpen: () => void }) { + useShortcut("composer.model", onOpen); + return null; +} + +/** Shown when the picked/current model reports `reasoning: false`. */ +export const NO_REASONING_WARNING = + "This model reports no reasoning support — a tier may be ignored."; + +export interface EffortPickerProps { + /** The tier in force, as a wire value or tier name: the draft's pending + * pick, or the live session's resolved_model.reasoning_effort ("" = auto). */ + value: string; + /** Receives the WIRE value: "" for auto, else the tier. */ + onPick: (wire: string) => void; + /** Live chat: the pick forks the chat; the note above the tiers says so. */ + live?: boolean; + /** The picked/current model's `reasoning` capability flag. `false` renders + * the no-reasoning warning; undefined (auto-routed, or an unlisted model) + * renders nothing — Studio never warns on a guess. */ + modelReasoning?: boolean; +} + +/** "{model} · {effort}" — the composer trigger's label in both modes. */ +export function effortTriggerLabel(modelLabel: string, value: string): string { + return `${modelLabel} · ${effortLabel(value)}`; +} + +const ITEM_CLASS = + "flex items-center gap-3 rounded-lg px-3 py-3 text-sm cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800 justify-between"; + +/** The desktop Effort submenu's body: note, warning, and one row per tier. */ +export function EffortMenuItems({ + value, + onPick, + live, + modelReasoning, +}: EffortPickerProps) { + const current = effortTier(value); + return ( + <> + {modelReasoning === false && ( +

+ {NO_REASONING_WARNING} +

+ )} + {EFFORT_TIERS.map((tier) => { + const isSelected = tier === current; + return ( + onPick(effortWire(tier))} + > + {effortLabel(tier)} + + + ); + })} + + ); +} + +/** The desktop dropdown's Effort submenu (trigger + the tier list). */ +export function EffortSubmenu(props: EffortPickerProps) { + return ( + + + Effort + + {effortLabel(props.value)} + + + + + + + ); +} + +/** + * The mobile picker sheet's Effort section: the section heading, the + * live/warning notes, and one tappable row per tier (the sheet's option-row + * idiom — label, trailing checkmark). Closing the sheet after a live pick is + * the caller's `onPick`, the way its model rows do. + */ +export function EffortSheetSection({ + value, + onPick, + live, + modelReasoning, +}: EffortPickerProps) { + const current = effortTier(value); + return ( +
+ + Effort + + {modelReasoning === false && ( +

+ {NO_REASONING_WARNING} +

+ )} + {EFFORT_TIERS.map((tier) => { + const isSelected = tier === current; + return ( + + ); + })} +
+ ); +} diff --git a/studio/src/app/workspace/_components/mcp-composer-insert.tsx b/studio/src/app/workspace/_components/mcp-composer-insert.tsx new file mode 100644 index 0000000000..7a7c52a2e6 --- /dev/null +++ b/studio/src/app/workspace/_components/mcp-composer-insert.tsx @@ -0,0 +1,204 @@ +"use client"; + +import type { Editor } from "@tiptap/react"; +import { FileText, MessageSquareQuote, Plug } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Kbd } from "@/components/ui/kbd"; +import { useOptionalRuntimeStatus } from "@/features/agent/runtime-status"; +import { useShortcutBindings } from "@/lib/shortcuts/keymap"; +import { keycaps } from "@/lib/shortcuts/registry"; +import { useShortcut } from "@/lib/shortcuts/use-shortcuts"; +import { appendComposerText } from "./composer-insert"; +import { McpPromptPicker } from "./mcp-prompt-picker"; +import { McpResourcePicker } from "./mcp-resource-picker"; + +/** + * "Insert from MCP" — the composer's entry to mecatui's f8 prompts picker and + * ctrl+r resources picker. Owned by `ChatInput` through `useMcpComposerInsert`, + * which holds the open picker, registers the two Tools shortcuts + * (`mcp.prompts`, `mcp.resources`) and appends the picked text AFTER the + * current draft (`appendComposerText`) — reviewed, never sent. + * + * Capability-gated on the daemon's `capabilities.mcp`: without it the menu + * renders nothing, the shortcuts stay unregistered (the chords keep their + * native meaning) and an outside open request is refused, so a daemon that + * grants only `mcp_connector_status` never receives a prompt or resource RPC + * (the TUI invariant). + */ + +export type McpPickerKind = "prompt" | "resource"; + +export const MCP_INSERT_LABEL = "Insert from MCP"; +const MCP_INSERT_PROMPT_ITEM = "Prompt…"; +const MCP_INSERT_RESOURCE_ITEM = "Resource…"; + +// ── Open requests from outside the composer ───────────────────────────────── +// +// A slash built-in or another surface asks the composer to open a picker +// through `requestOpenMcpPicker`. The composer that most recently mounted or +// held focus is the one that answers (a thread-panel composer next to the +// main one takes over while it has the caret), so exactly one dialog opens. + +let currentOpener: ((kind: McpPickerKind) => void) | null = null; + +/** + * Opens the given picker in the active composer. False when no composer is + * mounted or the daemon lacks the `mcp` capability — the caller then says so + * instead of silently doing nothing. + */ +export function requestOpenMcpPicker(kind: McpPickerKind): boolean { + if (!currentOpener) return false; + currentOpener(kind); + return true; +} + +/** Test seam: forgets the registered composer. */ +export function resetMcpPickerOpener(): void { + currentOpener = null; +} + +export interface McpComposerInsert { + /** The daemon grants `capabilities.mcp`: the entry and shortcuts are live. */ + available: boolean; + /** Opens one picker (no-op while unavailable). */ + open: (kind: McpPickerKind) => void; + /** The two picker dialogs; render once inside the composer. */ + dialogs: React.ReactNode; +} + +export function useMcpComposerInsert(editor: Editor | null): McpComposerInsert { + // A composer rendered outside the workspace shell (a display-only surface, + // a unit test) has no daemon capabilities: the entry simply stays hidden. + const runtime = useOptionalRuntimeStatus(); + const available = runtime?.serverCapabilities.mcp === true; + const [picker, setPicker] = useState(null); + + const open = useCallback( + (kind: McpPickerKind) => { + if (!available) return; + setPicker(kind); + }, + [available], + ); + + // Claim outside open requests: on mount, and again whenever this editor + // takes focus, so the composer under the caret is the one that answers. + useEffect(() => { + if (!available) return; + const claim = () => { + currentOpener = open; + }; + const onFocusIn = () => { + if (editor?.isFocused) claim(); + }; + claim(); + document.addEventListener("focusin", onFocusIn); + return () => { + document.removeEventListener("focusin", onFocusIn); + if (currentOpener === open) currentOpener = null; + }; + }, [available, open, editor]); + + useShortcut("mcp.prompts", () => open("prompt"), { enabled: available }); + useShortcut("mcp.resources", () => open("resource"), { enabled: available }); + + const insert = useCallback( + (text: string) => { + if (!editor || !text) return; + appendComposerText(editor, text); + }, + [editor], + ); + + const onOpenChange = useCallback((next: boolean) => { + if (!next) setPicker(null); + }, []); + + const dialogs = useMemo( + () => + available ? ( + <> + + + + ) : null, + [available, picker, onOpenChange, insert], + ); + + return useMemo( + () => ({ available, open, dialogs }), + [available, open, dialogs], + ); +} + +/** + * The desktop toolbar entry: a plug button opening a two-item menu. Renders + * nothing while the daemon lacks the `mcp` capability. + */ +export function McpInsertMenu({ + insert, + disabled = false, +}: { + insert: McpComposerInsert; + disabled?: boolean; +}) { + if (!insert.available) return null; + return ( + + + + + + insert.open("prompt")}> + + {MCP_INSERT_PROMPT_ITEM} + + + insert.open("resource")}> + + {MCP_INSERT_RESOURCE_ITEM} + + + + + ); +} + +/** The EFFECTIVE chord (Settings → Keyboard overrides included) as keycaps. */ +function ShortcutHint({ id }: { id: string }) { + const { bindings } = useShortcutBindings(); + const binding = bindings.find((b) => b.id === id); + if (!binding) return null; + return ( + + ); +} diff --git a/studio/src/app/workspace/_components/mcp-picker-shared.tsx b/studio/src/app/workspace/_components/mcp-picker-shared.tsx new file mode 100644 index 0000000000..f27c47450e --- /dev/null +++ b/studio/src/app/workspace/_components/mcp-picker-shared.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { isNoMcpProvider, NO_MCP_PROVIDER_TEXT } from "@/lib/harness/mcp"; +import { isUnsupportedByDaemon } from "@/lib/harness/sdk"; + +/** + * What the composer's two MCP pickers (prompts, resources) share: the + * on-open listing lifecycle with its abort, the daemon-error wording, the + * server filter and the type-to-filter input. Both pickers are admitted by + * `capabilities.mcp` only — the caller gates; nothing here fetches unless + * opened. + */ + +/** The server filter's "every server" value (Radix Select refuses ""). */ +export const ALL_SERVERS = "__all__"; + +/** The label every picker's insert action carries (the TUI's Choose). */ +export const INSERT_INTO_MESSAGE = "Insert into message"; + +/** + * The user-facing sentence for a failed MCP read: the daemon's + * `no_mcp_provider` refusal is a plain notice, an older daemon that lacks + * the route reads `unsupportedText`, anything else is its own message. + */ +export function mcpPickerErrorText( + error: unknown, + unsupportedText: string, +): string { + if (isNoMcpProvider(error)) return NO_MCP_PROVIDER_TEXT; + if (isUnsupportedByDaemon(error)) return unsupportedText; + return error instanceof Error ? error.message : String(error); +} + +export type PickerListing = + | { status: "loading" } + | { status: "ready"; items: T[] } + | { status: "error"; message: string }; + +/** + * Loads a listing every time the picker OPENS (a re-open re-reads, since + * servers may have changed), aborts a read left behind by a close or unmount, + * and words a failure through `mcpPickerErrorText`. + */ +export function useMcpPickerListing( + open: boolean, + load: (signal: AbortSignal) => Promise, + unsupportedText: string, +): PickerListing { + const [listing, setListing] = useState>({ + status: "loading", + }); + // The loader is read through a ref so an inline lambda never re-triggers + // the read; only the open flip does. + const loadRef = useRef(load); + loadRef.current = load; + + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + setListing({ status: "loading" }); + loadRef + .current(controller.signal) + .then((items) => { + if (!controller.signal.aborted) setListing({ status: "ready", items }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setListing({ + status: "error", + message: mcpPickerErrorText(error, unsupportedText), + }); + }); + return () => controller.abort(); + }, [open, unsupportedText]); + + return listing; +} + +/** The distinct, non-empty server names of a listing, in first-seen order. */ +export function distinctServers( + items: readonly { server: string }[], +): string[] { + const out: string[] = []; + for (const item of items) { + if (item.server && !out.includes(item.server)) out.push(item.server); + } + return out; +} + +/** Case-insensitive substring match of `query` against any of `fields`. */ +export function matchesQuery( + query: string, + fields: readonly string[], +): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return fields.some((field) => field.toLowerCase().includes(needle)); +} + +/** + * The filter row above a picker's list: type-to-filter, and — only when the + * listing spans more than one server — a server Select. + */ +export function PickerFilters({ + idPrefix, + query, + onQueryChange, + queryLabel, + servers, + server, + onServerChange, +}: { + idPrefix: string; + query: string; + onQueryChange: (query: string) => void; + queryLabel: string; + servers: string[]; + server: string; + onServerChange: (server: string) => void; +}) { + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder={queryLabel} + autoComplete="off" + /> +
+ {servers.length > 1 && ( +
+ + +
+ )} +
+ ); +} + +/** The picker's "reading…" line. */ +export function PickerBusy({ children }: { children: React.ReactNode }) { + return ( +

+

+ ); +} diff --git a/studio/src/app/workspace/_components/mcp-prompt-picker.test.tsx b/studio/src/app/workspace/_components/mcp-prompt-picker.test.tsx new file mode 100644 index 0000000000..3e80cc1e4a --- /dev/null +++ b/studio/src/app/workspace/_components/mcp-prompt-picker.test.tsx @@ -0,0 +1,271 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HarnessApiError } from "@/lib/harness/errors"; +import type { McpPromptView, McpRenderedPrompt } from "@/lib/harness/mcp"; +import { NO_MCP_PROVIDER_TEXT } from "@/lib/harness/mcp"; +import { + argumentsToSend, + MCP_PROMPTS_UNSUPPORTED_TEXT, + McpPromptPicker, + NO_MCP_PROMPTS_TEXT, + PROMPT_PICKER_TITLE, + promptLabel, + requiredArgumentsFilled, +} from "./mcp-prompt-picker"; + +/** + * The composer's MCP prompt picker (mecatui's f8 mcpPrompts → mcpPromptArgs): + * lists the prompts with title-or-name, server badge and description; filters + * as you type; a prompt with arguments opens a form whose Render is held + * until every REQUIRED argument is filled, fields in argument order for Tab; + * the preview shows every rendered message; Insert hands the role-prefixed, + * blank-line-joined text to the caller and closes; a prompt without + * arguments renders straight from the list; the empty listing, the daemon's + * no_mcp_provider refusal, an older daemon and any other error each read as + * their own sentence. + */ + +const mcp = vi.hoisted(() => ({ + list: vi.fn< + (server?: string, signal?: AbortSignal) => Promise + >(), + get: vi.fn< + ( + server: string, + name: string, + args: Record, + ) => Promise + >(), +})); +vi.mock("@/lib/harness/mcp", async (importOriginal) => ({ + ...(await importOriginal()), + listHarnessMcpPrompts: (server?: string, signal?: AbortSignal) => + mcp.list(server, signal), + getHarnessMcpPrompt: ( + server: string, + name: string, + args: Record, + ) => mcp.get(server, name, args), +})); + +const summarize: McpPromptView = { + server: "github", + name: "summarize", + title: "Summarize a file", + description: "Summarizes one workspace file.", + arguments: [ + { + name: "path", + title: "File path", + description: "Workspace-relative path.", + required: true, + }, + { name: "tone", title: "", description: "", required: false }, + ], +}; +const greet: McpPromptView = { + server: "slack", + name: "greet", + title: "", + description: "", + arguments: [], +}; + +afterEach(() => { + mcp.list.mockReset(); + mcp.get.mockReset(); +}); + +function renderPicker(onInsert = vi.fn(), onOpenChange = vi.fn()) { + render( + , + ); + return { onInsert, onOpenChange }; +} + +describe("helpers", () => { + it("labels a prompt by title, else name", () => { + expect(promptLabel(summarize)).toBe("Summarize a file"); + expect(promptLabel(greet)).toBe("greet"); + }); + + it("holds Render until every required argument is non-blank", () => { + expect(requiredArgumentsFilled(summarize, {})).toBe(false); + expect(requiredArgumentsFilled(summarize, { path: " " })).toBe(false); + expect(requiredArgumentsFilled(summarize, { path: "a.md" })).toBe(true); + expect(requiredArgumentsFilled(greet, {})).toBe(true); + }); + + it("sends only the non-blank arguments", () => { + expect(argumentsToSend({ path: "a.md", tone: "", extra: " " })).toEqual({ + path: "a.md", + }); + }); +}); + +describe("McpPromptPicker", () => { + it("lists prompts with title-or-name, server badge and description, and filters as you type", async () => { + mcp.list.mockResolvedValue([summarize, greet]); + renderPicker(); + expect( + screen.getByRole("dialog", { name: PROMPT_PICKER_TITLE }), + ).toBeInTheDocument(); + const rows = await screen.findAllByTestId("mcp-prompt-row"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent("Summarize a file"); + expect(rows[0]).toHaveTextContent("github"); + expect(rows[0]).toHaveTextContent("Summarizes one workspace file."); + expect(rows[0]).toHaveTextContent("2 arguments"); + expect(rows[1]).toHaveTextContent("greet"); + expect(mcp.list).toHaveBeenCalledWith("", expect.any(AbortSignal)); + + fireEvent.change(screen.getByLabelText("Filter prompts"), { + target: { value: "gre" }, + }); + expect(screen.getAllByTestId("mcp-prompt-row")).toHaveLength(1); + expect(screen.getByTestId("mcp-prompt-row")).toHaveTextContent("greet"); + }); + + it("holds Render until the required argument is filled, tabs through the fields in order, previews every message and inserts the joined text", async () => { + const user = userEvent.setup(); + mcp.list.mockResolvedValue([summarize]); + mcp.get.mockResolvedValue({ + description: "Summarizes one workspace file.", + messages: [ + { role: "user", text: "Summarize README.md" }, + { role: "assistant", text: "Reading it." }, + ], + }); + const { onInsert, onOpenChange } = renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-prompt-row")); + + const path = screen.getByLabelText(/File path/); + const tone = screen.getByLabelText(/^tone/); + expect(path).toHaveAttribute("aria-required", "true"); + expect(tone).toHaveAttribute("aria-required", "false"); + expect(screen.getByText("Workspace-relative path.")).toBeInTheDocument(); + const renderButton = screen.getByRole("button", { name: "Render" }); + expect(renderButton).toBeDisabled(); + + // Tab order follows the argument order. + expect(path).toHaveFocus(); + await user.tab(); + expect(tone).toHaveFocus(); + await user.tab({ shift: true }); + expect(path).toHaveFocus(); + + await user.type(path, "README.md"); + expect(renderButton).toBeEnabled(); + fireEvent.click(renderButton); + + expect(await screen.findByText("Summarize README.md")).toBeInTheDocument(); + expect(screen.getByText("Reading it.")).toBeInTheDocument(); + expect(screen.getByText("user")).toBeInTheDocument(); + expect(screen.getByText("assistant")).toBeInTheDocument(); + // Blank optional fields are left out of the request. + expect(mcp.get).toHaveBeenCalledWith("github", "summarize", { + path: "README.md", + }); + + fireEvent.click( + screen.getByRole("button", { name: "Insert into message" }), + ); + expect(onInsert).toHaveBeenCalledWith( + "user: Summarize README.md\n\nassistant: Reading it.", + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("submits the argument form on Enter once it is complete", async () => { + const user = userEvent.setup(); + mcp.list.mockResolvedValue([summarize]); + mcp.get.mockResolvedValue({ description: "", messages: [] }); + renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-prompt-row")); + const path = screen.getByLabelText(/File path/); + await user.type(path, "{Enter}"); + expect(mcp.get).not.toHaveBeenCalled(); + await user.type(path, "a.md{Enter}"); + await waitFor(() => + expect(mcp.get).toHaveBeenCalledWith("github", "summarize", { + path: "a.md", + }), + ); + }); + + it("renders a prompt without arguments straight from the list, and Back returns to the list", async () => { + mcp.list.mockResolvedValue([greet]); + mcp.get.mockResolvedValue({ + description: "", + messages: [{ role: "", text: "Hello there" }], + }); + renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-prompt-row")); + expect(await screen.findByText("Hello there")).toBeInTheDocument(); + expect(mcp.get).toHaveBeenCalledWith("slack", "greet", {}); + expect(screen.queryByRole("button", { name: "Render" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect(await screen.findByTestId("mcp-prompt-row")).toBeInTheDocument(); + }); + + it("says so when no prompt is exposed", async () => { + mcp.list.mockResolvedValue([]); + renderPicker(); + expect(await screen.findByText(NO_MCP_PROMPTS_TEXT)).toBeInTheDocument(); + }); + + it("words the daemon's no_mcp_provider refusal, an older daemon and any other error", async () => { + mcp.list.mockRejectedValueOnce( + new HarnessApiError(412, "no_mcp_provider", "no provider"), + ); + const first = render( + {}} onInsert={() => {}} />, + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + NO_MCP_PROVIDER_TEXT, + ); + first.unmount(); + + mcp.list.mockRejectedValueOnce( + new HarnessApiError(404, "not_found", "no such route"), + ); + const second = render( + {}} onInsert={() => {}} />, + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + MCP_PROMPTS_UNSUPPORTED_TEXT, + ); + second.unmount(); + + mcp.list.mockRejectedValueOnce(new Error("server github: timeout")); + render( + {}} onInsert={() => {}} />, + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "server github: timeout", + ); + }); + + it("shows a render failure verbatim and keeps the form", async () => { + mcp.list.mockResolvedValue([greet]); + mcp.get.mockRejectedValue(new Error("prompt greet: not found")); + renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-prompt-row")); + expect(await screen.findByRole("alert")).toHaveTextContent( + "prompt greet: not found", + ); + expect(screen.getByTestId("mcp-prompt-row")).toBeInTheDocument(); + }); + + it("reads nothing while closed", () => { + render( + {}} + onInsert={() => {}} + />, + ); + expect(mcp.list).not.toHaveBeenCalled(); + }); +}); diff --git a/studio/src/app/workspace/_components/mcp-prompt-picker.tsx b/studio/src/app/workspace/_components/mcp-prompt-picker.tsx new file mode 100644 index 0000000000..38675995dd --- /dev/null +++ b/studio/src/app/workspace/_components/mcp-prompt-picker.tsx @@ -0,0 +1,441 @@ +"use client"; + +import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + clampComposerInsert, + getHarnessMcpPrompt, + listHarnessMcpPrompts, + type McpPromptView, + type McpRenderedPrompt, + renderPromptForComposer, +} from "@/lib/harness/mcp"; +import { + ALL_SERVERS, + distinctServers, + INSERT_INTO_MESSAGE, + matchesQuery, + mcpPickerErrorText, + PickerBusy, + PickerFilters, + useMcpPickerListing, +} from "./mcp-picker-shared"; + +/** + * The composer's MCP prompt picker — mecatui's f8 `mcpPrompts` → + * `mcpPromptArgs` flow as a three-step dialog: (1) the prompts the connected + * servers expose (`GET /v1/mcp/prompts`), filtered by text and server; (2) a + * form with one field per argument, Render held until every required one is + * filled; (3) the rendered messages (`POST /v1/mcp/prompts/get`) to review. + * "Insert into message" hands the text to the composer — it is never sent + * on the user's behalf (the TUI's "press enter to send" contract). A prompt + * without arguments renders straight from the list. + */ + +export const PROMPT_PICKER_TITLE = "Insert an MCP prompt"; +const PROMPT_PICKER_DESCRIPTION = + "Pick a prompt from a connected MCP server, fill in its arguments and review the result. The text lands in your message for you to edit and send."; +export const NO_MCP_PROMPTS_TEXT = + "No MCP prompts are exposed by the connected servers."; +export const MCP_PROMPTS_UNSUPPORTED_TEXT = + "This daemon does not serve MCP prompts."; +const RENDER_PROMPT_LABEL = "Render"; + +type Step = + | { kind: "list" } + | { kind: "args"; prompt: McpPromptView } + | { kind: "preview"; prompt: McpPromptView; rendered: McpRenderedPrompt }; + +/** The prompt's display name: title, else its programmatic name. */ +export function promptLabel(prompt: Pick) { + return prompt.title || prompt.name; +} + +/** True once every REQUIRED argument holds a non-blank value. */ +export function requiredArgumentsFilled( + prompt: Pick, + values: Readonly>, +): boolean { + return prompt.arguments.every( + (argument) => !argument.required || (values[argument.name] ?? "").trim(), + ); +} + +/** The arguments actually sent: blank optional fields are left out. */ +export function argumentsToSend( + values: Readonly>, +): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (value.trim()) out[name] = value; + } + return out; +} + +export interface McpPromptPickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Receives the rendered, clamped text to append to the composer. */ + onInsert: (text: string) => void; +} + +export function McpPromptPicker({ + open, + onOpenChange, + onInsert, +}: McpPromptPickerProps) { + const idPrefix = useId(); + const listing = useMcpPickerListing( + open, + (signal) => listHarnessMcpPrompts("", signal), + MCP_PROMPTS_UNSUPPORTED_TEXT, + ); + const [step, setStep] = useState({ kind: "list" }); + const [query, setQuery] = useState(""); + const [server, setServer] = useState(ALL_SERVERS); + const [values, setValues] = useState>({}); + const [rendering, setRendering] = useState(false); + const [renderError, setRenderError] = useState(null); + + // A fresh pick per opening: a half-filled form must not ride into the + // next prompt. + useEffect(() => { + if (open) { + setStep({ kind: "list" }); + setQuery(""); + setServer(ALL_SERVERS); + setValues({}); + setRendering(false); + setRenderError(null); + } + }, [open]); + + const prompts = listing.status === "ready" ? listing.items : []; + const servers = useMemo(() => distinctServers(prompts), [prompts]); + const visible = useMemo( + () => + prompts.filter( + (prompt) => + (server === ALL_SERVERS || prompt.server === server) && + matchesQuery(query, [ + prompt.title, + prompt.name, + prompt.description, + prompt.server, + ]), + ), + [prompts, server, query], + ); + + const render = useCallback( + async (prompt: McpPromptView, args: Record) => { + setRendering(true); + setRenderError(null); + try { + const rendered = await getHarnessMcpPrompt( + prompt.server, + prompt.name, + args, + ); + setStep({ kind: "preview", prompt, rendered }); + } catch (error) { + setRenderError(mcpPickerErrorText(error, MCP_PROMPTS_UNSUPPORTED_TEXT)); + } finally { + setRendering(false); + } + }, + [], + ); + + const pick = (prompt: McpPromptView) => { + setRenderError(null); + if (prompt.arguments.length === 0) { + void render(prompt, {}); + return; + } + setValues({}); + setStep({ kind: "args", prompt }); + }; + + const insert = (rendered: McpRenderedPrompt) => { + onInsert(clampComposerInsert(renderPromptForComposer(rendered.messages))); + onOpenChange(false); + }; + + const formId = `${idPrefix}-args`; + + let body: React.ReactNode; + let footer: React.ReactNode; + + if (step.kind === "list") { + body = ( +
+ + {listing.status === "loading" && ( + Loading MCP prompts… + )} + {listing.status === "error" && ( +

+ {listing.message} +

+ )} + {listing.status === "ready" && prompts.length === 0 && ( +

{NO_MCP_PROMPTS_TEXT}

+ )} + {listing.status === "ready" && + prompts.length > 0 && + visible.length === 0 && ( +

+ No prompt matches the filter. +

+ )} + {rendering && Rendering the prompt…} + {renderError && ( +

+ {renderError} +

+ )} + {visible.length > 0 && ( +
    + {visible.map((prompt) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + footer = ( + + ); + } else if (step.kind === "args") { + const { prompt } = step; + const canRender = !rendering && requiredArgumentsFilled(prompt, values); + // The fields and the footer's Render button share ONE form (wrapped + // below), so Enter in any field is the browser's implicit submission. + body = ( +
+

+ {promptLabel(prompt)} + {prompt.server && ( + · {prompt.server} + )} + {prompt.description && ( + + {prompt.description} + + )} +

+ {prompt.arguments.map((argument, index) => { + const fieldId = `${idPrefix}-arg-${index}`; + const hintId = argument.description ? `${fieldId}-hint` : undefined; + return ( +
+ + + setValues((prev) => ({ + ...prev, + [argument.name]: event.target.value, + })) + } + aria-required={argument.required} + aria-describedby={hintId} + autoComplete="off" + // The first field takes focus so the form is typeable at once. + autoFocus={index === 0} + /> + {hintId && ( +

+ {argument.description} +

+ )} +
+ ); + })} + {rendering && Rendering the prompt…} + {renderError && ( +

+ {renderError} +

+ )} +
+ ); + footer = ( + <> + + + + ); + } else { + const { prompt, rendered } = step; + body = ( +
+

+ {promptLabel(prompt)} + {prompt.server && ( + · {prompt.server} + )} + {rendered.description && ( + + {rendered.description} + + )} +

+ {rendered.messages.length === 0 ? ( +

+ The server rendered no message for this prompt. +

+ ) : ( +
    + {rendered.messages.map((message, index) => ( +
  1. +

    + {message.role || "message"} +

    +
    +                  {message.text}
    +                
    +
  2. + ))} +
+ )} +
+ ); + footer = ( + <> + + + + ); + } + + return ( + + + + {PROMPT_PICKER_TITLE} + {PROMPT_PICKER_DESCRIPTION} + + {step.kind === "args" ? ( +
{ + event.preventDefault(); + if (!rendering && requiredArgumentsFilled(step.prompt, values)) { + void render(step.prompt, argumentsToSend(values)); + } + }} + > + {body} + {footer} +
+ ) : ( + <> + {body} + {footer} + + )} +
+
+ ); +} diff --git a/studio/src/app/workspace/_components/mcp-resource-picker.test.tsx b/studio/src/app/workspace/_components/mcp-resource-picker.test.tsx new file mode 100644 index 0000000000..8a43fddc8b --- /dev/null +++ b/studio/src/app/workspace/_components/mcp-resource-picker.test.tsx @@ -0,0 +1,226 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HarnessApiError } from "@/lib/harness/errors"; +import type { McpResourceContent, McpResourceView } from "@/lib/harness/mcp"; +import { NO_MCP_PROVIDER_TEXT } from "@/lib/harness/mcp"; +import { + binaryChunkNote, + MCP_RESOURCES_UNSUPPORTED_TEXT, + McpResourcePicker, + NO_MCP_RESOURCES_TEXT, + NO_RESOURCE_TEXT, + PREVIEW_CHARS, + previewTruncationNote, + RESOURCE_PICKER_TITLE, + resourceLabel, +} from "./mcp-resource-picker"; + +/** + * The composer's MCP resource picker (mecatui's ctrl+r mcpResources → + * mcpResourcePrev): lists resources with title-or-name, URI, type and size; + * a click reads the resource into a preview pane; a long text is cut at + * PREVIEW_CHARS with a note while the WHOLE text is inserted; binary chunks + * are named and never inserted; a text-less resource cannot be inserted; + * and the empty listing and every error class read as their own sentence. + */ + +const mcp = vi.hoisted(() => ({ + list: vi.fn< + (server?: string, signal?: AbortSignal) => Promise + >(), + read: vi.fn<(server: string, uri: string) => Promise>(), +})); +vi.mock("@/lib/harness/mcp", async (importOriginal) => ({ + ...(await importOriginal()), + listHarnessMcpResources: (server?: string, signal?: AbortSignal) => + mcp.list(server, signal), + readHarnessMcpResource: (server: string, uri: string) => + mcp.read(server, uri), +})); + +const notes: McpResourceView = { + server: "github", + uri: "file:///notes.txt", + name: "notes.txt", + title: "Release notes", + description: "What shipped.", + mimeType: "text/plain", + size: 2048, + readOnly: true, +}; +const bare: McpResourceView = { + server: "slack", + uri: "slack://channel/general", + name: "", + title: "", + description: "", + mimeType: "", + size: 0, + readOnly: false, +}; + +afterEach(() => { + mcp.list.mockReset(); + mcp.read.mockReset(); +}); + +function renderPicker(onInsert = vi.fn(), onOpenChange = vi.fn()) { + render( + , + ); + return { onInsert, onOpenChange }; +} + +describe("helpers", () => { + it("labels a resource by title, else name, else URI", () => { + expect(resourceLabel(notes)).toBe("Release notes"); + expect(resourceLabel({ ...notes, title: "" })).toBe("notes.txt"); + expect(resourceLabel(bare)).toBe("slack://channel/general"); + }); + + it("words a binary chunk by type and size, never inserted", () => { + expect(binaryChunkNote({ mimeType: "image/png", bytes: 1024 })).toBe( + "(binary image/png, 1 KB — not inserted)", + ); + expect(binaryChunkNote({ mimeType: "", bytes: 3 })).toContain( + "unknown type", + ); + }); + + it("words the truncation note with both counts", () => { + expect(previewTruncationNote(25_000)).toBe( + "Preview shows the first 20,000 of 25,000 characters; the whole text is inserted (up to 64 KiB).", + ); + }); +}); + +describe("McpResourcePicker", () => { + it("lists resources with label, server, type and size badges and the URI, and filters as you type", async () => { + mcp.list.mockResolvedValue([notes, bare]); + renderPicker(); + expect( + screen.getByRole("dialog", { name: RESOURCE_PICKER_TITLE }), + ).toBeInTheDocument(); + const rows = await screen.findAllByTestId("mcp-resource-row"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent("Release notes"); + expect(rows[0]).toHaveTextContent("github"); + expect(rows[0]).toHaveTextContent("text/plain"); + expect(rows[0]).toHaveTextContent("2 KB"); + expect(rows[0]).toHaveTextContent("file:///notes.txt"); + expect(rows[0]).toHaveTextContent("What shipped."); + expect(rows[1]).toHaveTextContent("slack://channel/general"); + expect(mcp.list).toHaveBeenCalledWith("", expect.any(AbortSignal)); + + fireEvent.change(screen.getByLabelText("Filter resources"), { + target: { value: "general" }, + }); + expect(screen.getAllByTestId("mcp-resource-row")).toHaveLength(1); + }); + + it("reads the picked resource into a preview and inserts its text, closing the dialog", async () => { + mcp.list.mockResolvedValue([notes]); + mcp.read.mockResolvedValue({ + text: "line one\nline two", + binaryChunks: [], + }); + const { onInsert, onOpenChange } = renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-resource-row")); + const preview = await screen.findByTestId("mcp-resource-preview"); + expect(preview).toHaveTextContent("line one line two"); + expect(mcp.read).toHaveBeenCalledWith("github", "file:///notes.txt"); + expect(screen.queryByRole("note")).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Insert into message" }), + ); + expect(onInsert).toHaveBeenCalledWith("line one\nline two"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("cuts a long preview with a note but inserts the whole text, and names binary chunks without inserting them", async () => { + const text = "x".repeat(PREVIEW_CHARS + 500); + mcp.list.mockResolvedValue([notes]); + mcp.read.mockResolvedValue({ + text, + binaryChunks: [{ mimeType: "image/png", bytes: 1024 }], + }); + const { onInsert } = renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-resource-row")); + const preview = await screen.findByTestId("mcp-resource-preview"); + expect(preview.textContent).toHaveLength(PREVIEW_CHARS); + expect(screen.getByRole("note")).toHaveTextContent( + previewTruncationNote(text.length), + ); + expect( + screen.getByText("(binary image/png, 1 KB — not inserted)"), + ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Insert into message" }), + ); + expect(onInsert).toHaveBeenCalledWith(text); + }); + + it("cannot insert a resource that holds no text, and Back returns to the list", async () => { + mcp.list.mockResolvedValue([notes]); + mcp.read.mockResolvedValue({ + text: "", + binaryChunks: [{ mimeType: "application/pdf", bytes: 7 }], + }); + renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-resource-row")); + expect(await screen.findByText(NO_RESOURCE_TEXT)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Insert into message" }), + ).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Back to list" })); + expect(await screen.findByTestId("mcp-resource-row")).toBeInTheDocument(); + }); + + it("says so when no resource is exposed", async () => { + mcp.list.mockResolvedValue([]); + renderPicker(); + expect(await screen.findByText(NO_MCP_RESOURCES_TEXT)).toBeInTheDocument(); + }); + + it("words the daemon's no_mcp_provider refusal, an older daemon and a read failure", async () => { + mcp.list.mockRejectedValueOnce( + new HarnessApiError(412, "no_mcp_provider", "no provider"), + ); + const first = render( + {}} onInsert={() => {}} />, + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + NO_MCP_PROVIDER_TEXT, + ); + first.unmount(); + + mcp.list.mockRejectedValueOnce( + new HarnessApiError(404, "not_found", "no such route"), + ); + const second = render( + {}} onInsert={() => {}} />, + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + MCP_RESOURCES_UNSUPPORTED_TEXT, + ); + second.unmount(); + + mcp.list.mockResolvedValue([notes]); + mcp.read.mockRejectedValue(new Error("resource gone")); + renderPicker(); + fireEvent.click(await screen.findByTestId("mcp-resource-row")); + expect(await screen.findByRole("alert")).toHaveTextContent("resource gone"); + expect(screen.getByTestId("mcp-resource-row")).toBeInTheDocument(); + }); + + it("reads nothing while closed", () => { + render( + {}} + onInsert={() => {}} + />, + ); + expect(mcp.list).not.toHaveBeenCalled(); + }); +}); diff --git a/studio/src/app/workspace/_components/mcp-resource-picker.tsx b/studio/src/app/workspace/_components/mcp-resource-picker.tsx new file mode 100644 index 0000000000..1ab82f3e55 Binary files /dev/null and b/studio/src/app/workspace/_components/mcp-resource-picker.tsx differ diff --git a/studio/src/app/workspace/_components/memory-indicator.tsx b/studio/src/app/workspace/_components/memory-indicator.tsx new file mode 100644 index 0000000000..bc40b9d671 --- /dev/null +++ b/studio/src/app/workspace/_components/memory-indicator.tsx @@ -0,0 +1,47 @@ +"use client"; + +/** + * The composer's memory indicator — READ-ONLY, derived from the daemon. + * + * Memory is a daemon setting (mecated's `--memory-dir` / `--no-user-model`), + * never a per-chat switch: the daemon registers Remember/Recall and wires the + * user-model store at spawn, and no session API turns either off. The pill + * therefore REPORTS what the daemon advertises in its compatibility document + * (`serverCapabilities.memory` — Remember/Recall registered, the TUI's + * "memory is on" welcome note — and `serverCapabilities.user_model`) and + * points at Settings → Memory, where the stores live. It replaced a local + * On/Off toggle that never reached the daemon and so implied a switch that + * did not exist. + */ + +/** The settings page that owns the memory stores. */ + +/** The two daemon-reported memory stores, as advertised on the capability + * document; `null` when the daemon reported nothing (an older daemon with no + * compatibility endpoint, or capabilities not loaded yet). */ +export interface MemoryStores { + /** `capabilities.memory`: the Remember/Recall tools are registered — the + * per-project cross-session store. */ + project: boolean | null; + /** `capabilities.user_model`: the cross-project user-model store is + * wired (the facts Settings → Memory lists). */ + userModel: boolean | null; +} + +/** Reads the two store flags off the wire-keyed (snake_case) capabilities. */ +export function readMemoryStores( + capabilities: Record, +): MemoryStores { + return { + project: flag(capabilities.memory), + userModel: flag(capabilities.user_model), + }; +} + +function flag(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +/** "on" when EITHER store is on (the agent has some long-term memory), "off" + * only when the daemon reported both off, "unknown" when it reported + * neither. */ diff --git a/studio/src/app/workspace/_components/model-picker-opener.test.tsx b/studio/src/app/workspace/_components/model-picker-opener.test.tsx new file mode 100644 index 0000000000..c5d7692766 --- /dev/null +++ b/studio/src/app/workspace/_components/model-picker-opener.test.tsx @@ -0,0 +1,95 @@ +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ModelPickerOpener, + registerModelPickerOpener, + requestOpenModelPicker, + resetModelPickerOpeners, +} from "./model-picker-opener"; + +/** + * The `/models` / `/effort` opener seam: the composer registers its desktop + * dropdown and mobile sheet (both always mounted, one CSS-hidden) and a + * request opens the one the viewport shows, falling back to whichever is + * registered; false — so the caller can say so — when none is. + */ + +function stubViewport(mobile: boolean) { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: query === "(max-width: 499px)" ? mobile : false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })), + ); +} + +beforeEach(() => { + resetModelPickerOpeners(); + stubViewport(false); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + resetModelPickerOpeners(); +}); + +describe("requestOpenModelPicker", () => { + it("is false with no composer registered", () => { + expect(requestOpenModelPicker()).toBe(false); + }); + + it("opens the desktop picker on a wide viewport and the sheet on a phone", () => { + const desktop = vi.fn(); + const mobile = vi.fn(); + registerModelPickerOpener("desktop", desktop); + registerModelPickerOpener("mobile", mobile); + expect(requestOpenModelPicker()).toBe(true); + expect(desktop).toHaveBeenCalledTimes(1); + expect(mobile).not.toHaveBeenCalled(); + + stubViewport(true); + expect(requestOpenModelPicker()).toBe(true); + expect(mobile).toHaveBeenCalledTimes(1); + expect(desktop).toHaveBeenCalledTimes(1); + }); + + it("falls back to whichever surface is registered", () => { + const mobile = vi.fn(); + registerModelPickerOpener("mobile", mobile); + expect(requestOpenModelPicker()).toBe(true); + expect(mobile).toHaveBeenCalledTimes(1); + }); + + it("unregisters only the opener that was registered", () => { + const first = vi.fn(); + const second = vi.fn(); + const unregisterFirst = registerModelPickerOpener("desktop", first); + registerModelPickerOpener("desktop", second); + // The stale unregister must not evict the newer registration. + unregisterFirst(); + expect(requestOpenModelPicker()).toBe(true); + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + }); +}); + +describe("ModelPickerOpener", () => { + it("registers while mounted, reads the latest onOpen, and unregisters on unmount", () => { + const first = vi.fn(); + const view = render(); + expect(requestOpenModelPicker()).toBe(true); + expect(first).toHaveBeenCalledTimes(1); + + const second = vi.fn(); + view.rerender(); + expect(requestOpenModelPicker()).toBe(true); + expect(second).toHaveBeenCalledTimes(1); + expect(first).toHaveBeenCalledTimes(1); + + view.unmount(); + expect(requestOpenModelPicker()).toBe(false); + }); +}); diff --git a/studio/src/app/workspace/_components/model-picker-opener.tsx b/studio/src/app/workspace/_components/model-picker-opener.tsx new file mode 100644 index 0000000000..dfe5a6ac13 --- /dev/null +++ b/studio/src/app/workspace/_components/model-picker-opener.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Outside open requests for the composer's model and effort picker — the + * `/models` and `/effort` built-ins' way in (the TUI's F7 analogue). + * + * Mirrors `requestOpenMcpPicker` (mcp-composer-insert.tsx): the composer + * registers its two pickers while mounted — the desktop dropdown and the + * mobile sheet are BOTH always in the tree, one hidden by CSS — and a + * request opens the one the viewport shows. False when no picker is + * registered (a composer with the model locked, an AI-debug chat) so the + * caller can say so instead of doing nothing. + */ + +export type ModelPickerSurface = "desktop" | "mobile"; + +const openers: Partial void>> = {}; + +/** Registers one surface's opener; returns the unregister. */ +export function registerModelPickerOpener( + surface: ModelPickerSurface, + open: () => void, +): () => void { + openers[surface] = open; + return () => { + if (openers[surface] === open) delete openers[surface]; + }; +} + +/** The composer's own breakpoint (chat-input.tsx `mobileViewport`); false + * where `matchMedia` is missing (a server render, a bare jsdom). */ +function mobileViewport(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(max-width: 499px)").matches + ); +} + +/** Opens the picker the current viewport shows; false when none answers. */ +export function requestOpenModelPicker(): boolean { + const preferred: ModelPickerSurface = mobileViewport() ? "mobile" : "desktop"; + const open = openers[preferred] ?? openers.desktop ?? openers.mobile; + if (!open) return false; + open(); + return true; +} + +/** Test seam: forgets every registered opener. */ +export function resetModelPickerOpeners(): void { + delete openers.desktop; + delete openers.mobile; +} + +/** + * Renders nothing; registers `onOpen` for `surface` while mounted. Placed + * next to the picker's open-state owner, like `ModelPickerShortcut`. The + * latest `onOpen` is read through a ref, so an inline arrow never + * re-registers the opener on every render. + */ +export function ModelPickerOpener({ + surface, + onOpen, +}: { + surface: ModelPickerSurface; + onOpen: () => void; +}) { + const ref = useRef(onOpen); + ref.current = onOpen; + useEffect( + () => registerModelPickerOpener(surface, () => ref.current()), + [surface], + ); + return null; +} diff --git a/studio/src/app/workspace/_components/model-picker.test.tsx b/studio/src/app/workspace/_components/model-picker.test.tsx new file mode 100644 index 0000000000..0f29cd491e --- /dev/null +++ b/studio/src/app/workspace/_components/model-picker.test.tsx @@ -0,0 +1,252 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { memoryStorage } from "@/test/memory-storage"; +import { ModelEffortSelector } from "./chat-input"; +import { + FILTER_MODELS_LABEL, + filterModelOptions, + NO_MODELS_MATCH, +} from "./model-picker"; + +const DEFAULT_KEY = "mecatl-studio.default-model"; + +const models = [ + { + id: "gpt-5", + label: "GPT-5", + providerId: "openai", + reasoning: true, + image: true, + contextLimit: 400_000, + }, + { + id: "claude-sonnet", + label: "Sonnet", + providerId: "anthropic", + reasoning: true, + image: false, + contextLimit: 200_000, + }, + { + id: "small", + label: "Small", + providerId: "openrouter", + reasoning: false, + image: false, + contextLimit: 0, + }, +]; + +/** The trigger is the only button before the menu opens. */ +const trigger = () => screen.getByRole("button"); + +/** Opens the root menu with a real pointer, then the Model submenu with a + * plain click (jsdom's zero-size rects make a simulated pointer move read + * to Radix as leaving the submenu). Returns the filter box. */ +async function openModels(user: ReturnType) { + await user.click(trigger()); + fireEvent.click(await screen.findByRole("menuitem", { name: /^Model/ })); + return screen.findByRole("combobox", { name: FILTER_MODELS_LABEL }); +} + +const rowNames = () => + screen.getAllByRole("option").map((row) => row.textContent ?? ""); + +/** + * The pure filter behind both pickers: a case-insensitive substring match + * over provider id, model id and display name. + */ +describe("filterModelOptions", () => { + it("matches provider, id and label case-insensitively", () => { + expect(filterModelOptions(models, "OPENAI").map((m) => m.id)).toEqual([ + "gpt-5", + ]); + expect(filterModelOptions(models, "sonnet").map((m) => m.id)).toEqual([ + "claude-sonnet", + ]); + expect(filterModelOptions(models, "gpt").map((m) => m.id)).toEqual([ + "gpt-5", + ]); + }); + + it("keeps every row for an empty or whitespace query", () => { + expect(filterModelOptions(models, "")).toHaveLength(3); + expect(filterModelOptions(models, " ")).toHaveLength(3); + }); + + it("returns nothing when nothing matches", () => { + expect(filterModelOptions(models, "gemini")).toEqual([]); + }); +}); + +/** + * The composer's Model submenu, driven through the real ModelEffortSelector + * (Radix menu + cmdk list): provider-qualified rows with glyphs and context, + * type-to-filter with a two-stage Escape, the `current:` provenance header, + * and the browser-local default for new chats (★ + set/clear). + */ +describe("Model picker", () => { + // jsdom exposes no localStorage here; the shared in-memory stub stands in + // (the global afterEach unstubs it, so every test starts with no default). + beforeEach(() => { + vi.stubGlobal("localStorage", memoryStorage()); + }); + + it("lists the models by name, grouped by provider, with no auto row, header or capability data", async () => { + const user = userEvent.setup(); + render( + {}} + onSwitchEffort={() => {}} + currentModelId="gpt-5" + currentEffort="" + autoModelLabel="Auto-routed" + />, + ); + await openModels(user); + // Provider groups in first-seen order, one heading each. + expect(screen.getByText("OpenAI")).toBeInTheDocument(); + expect(screen.getByText("Anthropic")).toBeInTheDocument(); + expect(screen.getByText("OpenRouter")).toBeInTheDocument(); + // Rows are the display names only: no provider prefix, no context + // window, no capability glyphs, and no "Default model"/auto row. + expect(rowNames()).toEqual(["GPT-5", "Sonnet", "Small"]); + expect( + screen.queryAllByRole("img", { name: "Accepts images" }), + ).toHaveLength(0); + expect(screen.queryAllByRole("img", { name: "Reasoning" })).toHaveLength(0); + expect(screen.queryByRole("option", { name: /Auto-routed/ })).toBeNull(); + expect(screen.queryByTestId("model-picker-current")).toBeNull(); + // The current model is marked. + expect(screen.getByRole("option", { name: /GPT-5/ })).toHaveAttribute( + "data-current", + "true", + ); + }); + + it("filters rows by provider, id or name and shows an empty state", async () => { + const user = userEvent.setup(); + render( + {}} + onSwitchEffort={() => {}} + currentModelId="" + currentEffort="" + />, + ); + const input = await openModels(user); + fireEvent.change(input, { target: { value: "anthro" } }); + await waitFor(() => expect(rowNames()).toEqual(["Sonnet"])); + fireEvent.change(input, { target: { value: "claude-son" } }); + await waitFor(() => expect(rowNames()).toHaveLength(1)); + fireEvent.change(input, { target: { value: "gemini" } }); + await waitFor(() => + expect(screen.getByText(NO_MODELS_MATCH)).toBeInTheDocument(), + ); + expect(screen.queryAllByRole("option")).toHaveLength(0); + }); + + it("two-stage Escape: clears a non-empty filter first, closes the menu when empty", async () => { + const user = userEvent.setup(); + render( + {}} + onSwitchEffort={() => {}} + currentModelId="" + currentEffort="" + />, + ); + const input = await openModels(user); + fireEvent.change(input, { target: { value: "small" } }); + await waitFor(() => expect(rowNames()).toHaveLength(1)); + fireEvent.keyDown(input, { key: "Escape" }); + // Stage one: the filter is cleared, the menu stays open. + await waitFor(() => expect(rowNames()).toHaveLength(3)); + expect( + screen.getByRole("combobox", { name: FILTER_MODELS_LABEL }), + ).toHaveValue(""); + fireEvent.keyDown(input, { key: "Escape" }); + // Stage two: Radix closes the whole menu. + await waitFor(() => + expect( + screen.queryByRole("combobox", { name: FILTER_MODELS_LABEL }), + ).toBeNull(), + ); + }); + + it("live chat: picking a row forks, re-picking the current one is a no-op", async () => { + const user = userEvent.setup(); + const onSwitchModel = vi.fn(); + render( + {}} + currentModelId="gpt-5" + currentEffort="" + />, + ); + await openModels(user); + fireEvent.click(screen.getByRole("option", { name: /GPT-5/ })); + expect(onSwitchModel).not.toHaveBeenCalled(); + // The menu closed on the pick (cmdk rows are not Radix items). + await waitFor(() => + expect( + screen.queryByRole("combobox", { name: FILTER_MODELS_LABEL }), + ).toBeNull(), + ); + await openModels(user); + fireEvent.click(screen.getByRole("option", { name: /Sonnet/ })); + expect(onSwitchModel).toHaveBeenCalledWith( + expect.objectContaining({ id: "claude-sonnet", providerId: "anthropic" }), + ); + }); + + it("draft: an untouched picker starts on the Studio default", async () => { + window.localStorage.setItem( + DEFAULT_KEY, + '{"modelId":"claude-sonnet","providerId":"anthropic"}', + ); + const user = userEvent.setup(); + render( + {}} + onEffortChange={() => {}} + />, + ); + // The trigger shows the default, not "Default model". + expect(trigger()).toHaveAttribute("title", "Sonnet · Auto"); + await openModels(user); + expect(screen.getByRole("option", { name: /Sonnet/ })).toHaveAttribute( + "data-current", + "true", + ); + expect(screen.queryByRole("option", { name: /Default model/ })).toBeNull(); + }); + + it("draft: a Studio default the inventory lacks is not applied — the daemon default stands", async () => { + window.localStorage.setItem( + DEFAULT_KEY, + '{"modelId":"gone","providerId":"nowhere"}', + ); + const user = userEvent.setup(); + render( + {}} + onEffortChange={() => {}} + />, + ); + expect(trigger()).toHaveAttribute("title", "Default · Auto"); + await openModels(user); + // No row wears the star for a default the inventory no longer lists. + expect( + screen.queryByRole("img", { name: "Your default for new chats" }), + ).toBeNull(); + }); +}); diff --git a/studio/src/app/workspace/_components/model-picker.tsx b/studio/src/app/workspace/_components/model-picker.tsx new file mode 100644 index 0000000000..9827c86e0c --- /dev/null +++ b/studio/src/app/workspace/_components/model-picker.tsx @@ -0,0 +1,353 @@ +"use client"; + +/** + * The model half of the composer's model + effort picker (the TUI's /models + * surface): the daemon's live inventory as provider-qualified rows — + * `provider · name`, capability glyphs (image input, reasoning), context + * window — behind a type-to-filter box, under a `current:` header that names + * the model in force and WHERE it came from (your pick / Studio default / + * daemon default), with a ★ on the browser-local default for new chats and + * an action to set or clear it (the ctrl+g analogue). + * + * The desktop list is a cmdk `Command` inside the Radix submenu — the shadcn + * "combobox in a dropdown" shape — because a bare text input fights Radix's + * roving focus and typeahead: cmdk owns arrow/Enter/Home/End from the input + * and the submenu holds no Radix items, so Radix's typeahead has nothing to + * steal focus for. Escape is two-stage like the TUI: a non-empty filter + * clears first (`onEscapeKeyDown` on the submenu — Radix listens on the + * document in the capture phase, so the input itself cannot intercept it), + * an empty one lets Radix close the menu. + */ + +import { Check, Star } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { DropdownMenuSubContent } from "@/components/ui/dropdown-menu"; +import { InputSearch } from "@/components/ui/input-search"; +import { useDefaultModel } from "@/lib/model-preferences"; +import { cn } from "@/lib/utils"; +import type { ComposerModelOption } from "./chat-input"; +import { isDefaultOption, type ModelProvenance } from "./draft-model"; + +/** Shown in place of the rows when the filter matches nothing. */ +export const NO_MODELS_MATCH = "No models match"; + +/** The filter box's accessible name (desktop and mobile alike). */ +export const FILTER_MODELS_LABEL = "Filter models"; + +/** + * Case-insensitive substring filter over provider id, model id and display + * name; an empty (or whitespace) query keeps every row. Callers pass the + * real models only — the auto row is pinned above the list, not filtered. + */ +export function filterModelOptions( + options: readonly ComposerModelOption[], + query: string, +): ComposerModelOption[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...options]; + return options.filter((option) => + [option.providerId ?? "", option.id, option.label].some((field) => + field.toLowerCase().includes(needle), + ), + ); +} + +/** cmdk selection value for a row: provider-qualified so the same model id + * on two providers stays two rows; the auto row has a fixed sentinel. */ +const AUTO_VALUE = "__auto__"; +function optionValue(option: ComposerModelOption): string { + return option.id === "" + ? AUTO_VALUE + : `${option.providerId ?? ""}/${option.id}`; +} + +/** + * One row's body: the model's display name, the ★ default marker and the + * current checkmark. Provider and capabilities are NOT repeated per row — + * the list is grouped by provider, and the details live on the model's + * own documentation, not in a picker a non-technical user scans by name. + */ +function ModelOptionBody({ + option, + isDefault, + isCurrent, +}: { + option: ComposerModelOption; + isDefault: boolean; + isCurrent: boolean; +}) { + return ( + <> + + {option.label} + {isDefault && ( + + )} + + + + ); +} + +/** Human name for a provider id as the group heading. */ +const PROVIDER_NAMES: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + openrouter: "OpenRouter", + toolhive: "ToolHive", + mock: "Offline mock", +}; +function providerGroupLabel(providerId: string): string { + if (!providerId) return "Other"; + return ( + PROVIDER_NAMES[providerId] ?? + providerId.charAt(0).toUpperCase() + providerId.slice(1) + ); +} + +/** The real models grouped by provider, in first-seen provider order. */ +function groupModelsByProvider( + options: readonly ComposerModelOption[], +): { providerId: string; label: string; options: ComposerModelOption[] }[] { + const groups = new Map(); + for (const option of options) { + const key = option.providerId ?? ""; + const bucket = groups.get(key); + if (bucket) bucket.push(option); + else groups.set(key, [option]); + } + return [...groups.entries()].map(([providerId, items]) => ({ + providerId, + label: providerGroupLabel(providerId), + options: items, + })); +} + +export interface ModelPickerListProps { + /** Every option, the auto row (id "") first. */ + options: readonly ComposerModelOption[]; + /** The model in force ("" = auto): the live session's, or the draft's + * resolved pick, for the checkmark. */ + selectedId: string; + /** Label of the model in force (kept for callers; the list shows no header). */ + currentLabel: string; + provenance: ModelProvenance; + /** Live chat: a pick forks the chat onto the model. */ + live: boolean; + onPick: (option: ComposerModelOption) => void; +} + +/** + * The desktop Model submenu — renders the `DropdownMenuSubContent` itself so + * it can own the filter state the two-stage Escape needs. + */ +export function ModelSubmenuContent({ + options, + selectedId, + onPick, +}: ModelPickerListProps) { + const [filter, setFilter] = useState(""); + const { defaultModel } = useDefaultModel(); + // The auto ("Default model") row is deliberately not offered: the list is + // the daemon's real models only. With no pick in force nothing is checked. + const real = options.filter((option) => option.id !== ""); + const rows = filterModelOptions(real, filter); + const _visible = rows; + const groups = groupModelsByProvider(rows); + const _selectedOption = real.find((option) => option.id === selectedId); + const inputRef = useRef(null); + + // Radix focuses the submenu container for keyboard users AFTER the + // input's own autoFocus; take the focus back a frame later so typing + // always lands in the filter. + useEffect(() => { + const frame = requestAnimationFrame(() => inputRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + + return ( + { + // Two-stage Escape: clear a filter first, close only when empty. + if (filter) { + event.preventDefault(); + setFilter(""); + } + }} + > + + { + // With text in the box the horizontal keys move the caret; keep + // them from Radix, whose ArrowLeft would close the submenu. + if ( + filter && + ["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key) + ) { + event.stopPropagation(); + } + }} + /> + + {groups.map((group) => ( + + {group.options.map((option) => { + const isCurrent = option.id === selectedId; + return ( + onPick(option)} + className={cn( + "flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm", + isCurrent && "bg-zinc-100 dark:bg-zinc-800", + )} + > + + + ); + })} + + ))} + {rows.length === 0 && ( +

+ {NO_MODELS_MATCH} +

+ )} +
+
+
+ ); +} + +/** + * The mobile picker sheet's Model section: heading, live note, the header, + * a filter box, one tappable row per model with a trailing ★ toggle for the + * default, and the same "No models match" empty state. + */ +export function ModelSheetSection({ + options, + selectedId, + onPick, +}: ModelPickerListProps) { + const [filter, setFilter] = useState(""); + const { defaultModel, setDefaultModel, clearDefaultModel } = + useDefaultModel(); + const real = options.filter((option) => option.id !== ""); + const rows = filterModelOptions(real, filter); + const groups = groupModelsByProvider(rows); + + return ( + <> +

+ Model +

+
+ +
+ {groups.map((group) => ( +
+

+ {group.label} +

+ {group.options.map((option) => { + const isCurrent = option.id === selectedId; + const isDefault = isDefaultOption(option, defaultModel); + const canDefault = option.id !== "" && Boolean(option.providerId); + return ( +
+ + {canDefault && ( + + )} +
+ ); + })} +
+ ))} + {rows.length === 0 && ( +

+ {NO_MODELS_MATCH} +

+ )} + + ); +} diff --git a/studio/src/app/workspace/_components/resize-handle.tsx b/studio/src/app/workspace/_components/resize-handle.tsx new file mode 100644 index 0000000000..ca3e94dc01 --- /dev/null +++ b/studio/src/app/workspace/_components/resize-handle.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useRef } from "react"; +import { cn } from "@/lib/utils"; + +interface ResizeHandleProps { + direction?: "left" | "right"; + width: number; + onWidthChange: (width: number) => void; + min?: number; + max?: number; +} + +export function ResizeHandle({ + direction = "right", + width, + onWidthChange, + min = 200, + max = 720, +}: ResizeHandleProps) { + const isDraggingRef = useRef(false); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-only resize handle +
{ + e.preventDefault(); + isDraggingRef.current = true; + const startX = e.clientX; + const startW = width; + const sign = direction === "right" ? 1 : -1; + const onMove = (ev: MouseEvent) => { + if (!isDraggingRef.current) return; + onWidthChange( + Math.max(min, Math.min(max, startW + sign * (ev.clientX - startX))), + ); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }} + /> + ); +} diff --git a/studio/src/app/workspace/_components/tool-profile-picker.test.tsx b/studio/src/app/workspace/_components/tool-profile-picker.test.tsx new file mode 100644 index 0000000000..d26e08364d --- /dev/null +++ b/studio/src/app/workspace/_components/tool-profile-picker.test.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SHELL_DISABLED_NOTE } from "@/lib/tool-profile"; +import { + ToolProfileSelector, + ToolProfileSheetRows, + toolProfileReadOnlyLine, +} from "./tool-profile-picker"; + +/** + * The composer's TOOLS choice (the daemon's per-session `profile`, ADR + * 0291) as its own pill next to Mode: + * + * - a DRAFT (`onProfileChange` wired) gets a dropdown with the two rows — + * All / None — and the pill names the pick; + * - a LIVE chat gets no dropdown: a KNOWN profile renders a read-only pill, + * an unknown one (a chat Studio did not mint) renders nothing; + * - a daemon whose Shell tool is off (`capabilities.bash === false`) gets + * the muted note; an older daemon that does not report it gets none. + */ + +const runtime = vi.hoisted(() => ({ + value: null as null | { serverCapabilities: Record }, +})); + +vi.mock("@/features/agent/runtime-status", () => ({ + useOptionalRuntimeStatus: () => runtime.value, +})); + +const pill = () => screen.getByTestId("tool-profile-pill"); + +async function openMenu(user: ReturnType) { + await user.click(pill()); + await screen.findByRole("menuitem", { name: /^All/ }); +} + +beforeEach(() => { + runtime.value = null; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("ToolProfileSelector (draft)", () => { + it("lists All and None, checks the current pick, and reports a change", async () => { + const user = userEvent.setup(); + const onProfileChange = vi.fn(); + render( + , + ); + expect(pill()).toHaveTextContent("Tools All"); + await openMenu(user); + const noFs = screen.getByRole("menuitem", { name: /^None/ }); + expect(noFs).toBeInTheDocument(); + fireEvent.click(noFs); + expect(onProfileChange).toHaveBeenCalledWith("no-fs"); + }); + + it("names the attenuated profile on the pill once picked", () => { + render(); + expect(pill()).toHaveTextContent("Tools None"); + expect(pill()).toHaveAttribute("title", "Tools: None"); + }); + + it("says when the daemon's Shell tool is off, and stays quiet when it is not reported", async () => { + const user = userEvent.setup(); + runtime.value = { serverCapabilities: { bash: false } }; + const view = render( + , + ); + await openMenu(user); + expect(screen.getByText(SHELL_DISABLED_NOTE)).toBeInTheDocument(); + view.unmount(); + + runtime.value = { serverCapabilities: {} }; + render(); + await openMenu(user); + expect(screen.queryByText(SHELL_DISABLED_NOTE)).toBeNull(); + }); +}); + +describe("ToolProfileSelector (live chat)", () => { + it("renders a known profile as a read-only pill", () => { + render(); + expect(pill()).toHaveTextContent("Tools None"); + expect(pill()).toHaveAttribute("title", toolProfileReadOnlyLine("no-fs")); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders nothing at all for a chat whose profile is unknown", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe("ToolProfileSheetRows (mobile)", () => { + it("offers the two rows on a draft and reports the pick", async () => { + const user = userEvent.setup(); + const onProfileChange = vi.fn(); + render( + , + ); + expect(screen.getByText("Tools")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^None/ })); + expect(onProfileChange).toHaveBeenCalledWith("no-fs"); + await user.click(screen.getByRole("button", { name: /^All/ })); + expect(onProfileChange).toHaveBeenCalledWith(""); + }); + + it("is read-only on a live chat with a known profile and absent when unknown", () => { + const known = render(); + expect(screen.getByText(toolProfileReadOnlyLine(""))).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); + known.unmount(); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the shell-off note under the rows", () => { + runtime.value = { serverCapabilities: { bash: false } }; + render(); + expect(screen.getByText(SHELL_DISABLED_NOTE)).toBeInTheDocument(); + }); +}); diff --git a/studio/src/app/workspace/_components/tool-profile-picker.tsx b/studio/src/app/workspace/_components/tool-profile-picker.tsx new file mode 100644 index 0000000000..40104739b8 --- /dev/null +++ b/studio/src/app/workspace/_components/tool-profile-picker.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useOptionalRuntimeStatus } from "@/features/agent/runtime-status"; +import { + type SessionToolProfile, + SHELL_DISABLED_NOTE, + shellDisabledOnDaemon, + TOOL_PROFILE_OPTIONS, + toolProfileLabel, +} from "@/lib/tool-profile"; +import { cn } from "@/lib/utils"; + +/** + * The composer's TOOLS choice — the daemon's per-session `profile` (ADR + * 0291): "All tools" or "No filesystem" (the file-less catalog: no Shell, + * Read, Edit, Write…; web tools remain). On desktop it is its OWN pill next + * to Mode (`ToolProfileSelector`); the mobile mode sheet keeps the rows. + * + * Two shapes, one prop contract: + * - `onProfileChange` present = a DRAFT: the rows pick the profile the first + * send mints with. + * - absent = a LIVE chat: the profile is fixed at create and the daemon + * never reports it back, so a KNOWN `profile` (Studio minted the chat) + * renders as a muted read-only line and an unknown one (undefined — the + * chat came from the TUI or a schedule) renders nothing at all. + * + * When the daemon reports its Shell tool OFF (`capabilities.bash === false`, + * the operator's `--no-shell`) a muted note says so under the rows: "All + * tools" then honestly means "all but Shell". Rendered outside the runtime + * provider (a unit test, a display-only composer) the note simply stays + * hidden. + */ + +/** Read-only wording for a live chat's remembered profile. */ +export function toolProfileReadOnlyLine(profile: SessionToolProfile): string { + return `Tools: ${toolProfileLabel(profile)} — set when this chat was created`; +} + +function useShellDisabledNote(): string | null { + const runtime = useOptionalRuntimeStatus(); + return shellDisabledOnDaemon(runtime?.serverCapabilities) + ? SHELL_DISABLED_NOTE + : null; +} + +/** The mobile mode sheet's Tools rows (the SheetOptionRow idiom). */ +export function ToolProfileSheetRows({ + profile, + onProfileChange, +}: { + profile?: SessionToolProfile; + onProfileChange?: (profile: SessionToolProfile) => void; +}) { + const shellNote = useShellDisabledNote(); + if (!onProfileChange && profile === undefined) return null; + return ( +
+

+ Tools +

+ {onProfileChange ? ( + TOOL_PROFILE_OPTIONS.map((option) => ( + + )) + ) : ( +

+ {toolProfileReadOnlyLine(profile ?? "")} +

+ )} + {shellNote && ( +

+ {shellNote} +

+ )} +
+ ); +} + +/** + * The desktop composer's Tools pill: a dropdown of the two profiles on a + * draft, a read-only pill on a live chat whose profile Studio remembers, + * nothing when the profile is unknown. + */ +export function ToolProfileSelector({ + profile, + onProfileChange, + disabled, + className, +}: { + profile?: SessionToolProfile; + onProfileChange?: (profile: SessionToolProfile) => void; + disabled?: boolean; + className?: string; +}) { + const shellNote = useShellDisabledNote(); + if (!onProfileChange && profile === undefined) return null; + const label = toolProfileLabel(profile ?? ""); + if (!onProfileChange) { + return ( + + + Tools {label} + + + ); + } + return ( + + + + + e.preventDefault()} + align="start" + className="w-72" + > + {TOOL_PROFILE_OPTIONS.map((option) => ( + onProfileChange(option.id)} + > + + + {option.label} + + {option.description} + + + + ))} + {shellNote && ( +

+ {shellNote} +

+ )} +
+
+ ); +} diff --git a/studio/src/app/workspace/chat/[[...sessionId]]/page.tsx b/studio/src/app/workspace/chat/[[...sessionId]]/page.tsx new file mode 100644 index 0000000000..d58a0bd8ad --- /dev/null +++ b/studio/src/app/workspace/chat/[[...sessionId]]/page.tsx @@ -0,0 +1,29 @@ +import { type ChatSeedParams, resolveChatSeed } from "@/lib/chat-seed"; +import { ChatWorkspace } from "../_components/chat-workspace"; + +/** + * `/workspace/chat` (a draft with no daemon session yet) and + * `/workspace/chat/` are served by one optional-catch-all route, + * so moving between them keeps `ChatWorkspace` mounted — a draft's in-flight + * stream survives the router.replace to its freshly minted session id. + * + * Deep link (the web analogue of `mecatui -p/--prompt-file`): + * `?prompt=` arrives with the composer pre-filled; `&send=1` adds a + * one-click confirmation showing the exact text, after which it is sent as + * the next turn and the chat stays interactive. The PWA share target + * (`app/manifest.ts`) lands here as `prompt`/`title`/`url`, prefill-only. + * The workspace strips the query once it has consumed it, so a reload or + * Back never re-seeds. See `lib/chat-seed.ts` for the rules. + */ +export default async function ChatPage({ + params, + searchParams, +}: { + params: Promise<{ sessionId?: string[] }>; + searchParams: Promise; +}) { + const [{ sessionId }, query] = await Promise.all([params, searchParams]); + return ( + + ); +} diff --git a/studio/src/app/workspace/chat/_components/approval-detail-panel.test.tsx b/studio/src/app/workspace/chat/_components/approval-detail-panel.test.tsx new file mode 100644 index 0000000000..b0af5379de --- /dev/null +++ b/studio/src/app/workspace/chat/_components/approval-detail-panel.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ApprovalRequest } from "@/features/agent"; +import { memoryStorage } from "@/test/memory-storage"; +import { ApprovalDetailPanel } from "./approval-detail-panel"; + +const shellAsk: ApprovalRequest = { + approvalId: "s1:1:c1:r1", + sessionId: "s1", + toolName: "Shell", + description: "Shell needs your approval.", + reason: "runs a command", + args: JSON.stringify({ command: "go test ./...", timeout_ms: 5000 }), + details: "runs a command\n\ncommand: go test ./... · timeout_ms: 5000", +}; + +function renderPanel( + approval: ApprovalRequest = shellAsk, + handlers: Partial<{ + onRespond: (choice: string) => void; + onClose: () => void; + }> = {}, +) { + const onRespond = vi.fn(handlers.onRespond); + const onClose = vi.fn(handlers.onClose); + render( + {}} + />, + ); + return { onRespond, onClose }; +} + +// The panel frame (SidePanel) persists its width in localStorage; this vitest +// environment's storage shim is method-less, so a real in-memory Storage is +// stubbed per test (the global afterEach unstubs it). +beforeEach(() => { + vi.stubGlobal("localStorage", memoryStorage()); +}); + +describe("ApprovalDetailPanel", () => { + it("titles the panel by tool, shows the reason and the decoded command with a raw toggle", () => { + renderPanel(); + expect(screen.getByText("Shell — permission ask")).toBeInTheDocument(); + expect(screen.getByText("Shell needs your approval.")).toBeInTheDocument(); + expect(screen.getByText("runs a command")).toBeInTheDocument(); + expect(screen.getByText("go test ./...")).toBeInTheDocument(); + expect(screen.getByText("timeout 5s")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Raw" })); + expect(screen.getByText(shellAsk.args as string)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Close permission details" }), + ).toBeInTheDocument(); + }); + + it("answers the ask from the pinned bar and closes the panel", () => { + const { onRespond, onClose } = renderPanel(); + fireEvent.click(screen.getByRole("button", { name: "Always allow" })); + expect(onRespond).toHaveBeenCalledWith("always"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("withholds Always allow for a child ask and names the subagent", () => { + const { onRespond } = renderPanel({ + ...shellAsk, + approvalId: "subagent-x:1:c1:r1", + child: true, + }); + expect( + screen.getByText("A subagent's Shell needs your approval."), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Deny" })); + expect(onRespond).toHaveBeenCalledWith("deny"); + }); + + it("closes without a verdict from the header close control", () => { + const { onRespond, onClose } = renderPanel(); + fireEvent.click( + screen.getByRole("button", { name: "Close permission details" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onRespond).not.toHaveBeenCalled(); + }); + + it("withholds Always allow for a debugger MCP ask in a debug session and shows the one-call note", () => { + const debugMcpAsk: ApprovalRequest = { + ...shellAsk, + approvalId: "dbg-1:1:c1:r1", + sessionId: "dbg-1", + toolName: "mcp__github__create_issue", + description: "mcp__github__create_issue needs your approval.", + reason: "debug MCP call requires fresh current operator approval", + args: JSON.stringify({ title: "Flaky scheduler test" }), + details: "", + }; + const onRespond = vi.fn(); + const onClose = vi.fn(); + render( + {}} + debugSession + />, + ); + expect(screen.getByTestId("debug-mcp-ask-note")).toHaveTextContent( + "one call at a time", + ); + expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Allow once" })); + expect(onRespond).toHaveBeenCalledWith("once"); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +/** + * An MCP ask is titled with the TUI's humanized `Server · Tool`; the + * destructive tint is judged on both the raw id and the title, so an MCP + * delete tool still paints Allow once red (the inline card does the same). + */ +describe("ApprovalDetailPanel MCP tool titles", () => { + const mcpAsk = (toolName: string): ApprovalRequest => ({ + ...shellAsk, + toolName, + description: `${toolName} needs your approval.`, + reason: "", + args: JSON.stringify({ owner: "stacklok" }), + details: "", + }); + + it("titles an MCP delete ask Server · Tool and keeps the destructive tint", () => { + renderPanel(mcpAsk("mcp__github__delete_branch")); + expect( + screen.getByText("GitHub · Delete branch — permission ask"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Allow once" })).toHaveClass( + "bg-destructive", + ); + }); + + it("keeps a non-destructive MCP ask amber", () => { + renderPanel(mcpAsk("mcp__github__issue_write")); + expect( + screen.getByText("GitHub · Issue write — permission ask"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Allow once" })).toHaveClass( + "bg-warning", + ); + }); +}); diff --git a/studio/src/app/workspace/chat/_components/approval-detail-panel.tsx b/studio/src/app/workspace/chat/_components/approval-detail-panel.tsx new file mode 100644 index 0000000000..a6e1ba7b1e --- /dev/null +++ b/studio/src/app/workspace/chat/_components/approval-detail-panel.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { ShieldAlert } from "lucide-react"; +import type { ApprovalChoice, ApprovalRequest } from "@/features/agent"; +import { + DEBUG_MCP_ASK_NOTE, + isDebugMcpMutationAsk, +} from "@/features/agent/approval-queue"; +import { isPlanAsk } from "@/features/agent/plan-ask"; +import { toolDisplayName } from "@/lib/tool-names"; +import { ApprovalVerdictBar } from "./approval-verdict-bar"; +import { askToolName } from "./ask-args"; +import { AskArgsView } from "./ask-args-view"; +import { PlanReviewDetailPanel } from "./plan-review-panel"; +import { SidePanel } from "./side-panel"; + +const DELETE_WORDS = /\b(delete|remove|drop|revoke|destroy|purge|rm)\b/i; + +/** + * The full-height view of one permission ask in the right-hand side panel + * (the TUI's ctrl+t args view): the reason and the decoded args fill and + * scroll the panel, with the Raw/Pretty toggle, and the verdict buttons stay + * pinned in a bottom bar. A verdict answers the ask and closes the panel. + */ +export function ApprovalDetailPanel({ + approval, + onRespond, + onClose, + maximized, + onToggleMaximize, + windowControls, + debugSession = false, +}: { + approval: ApprovalRequest; + onRespond: (choice: ApprovalChoice) => void; + onClose: () => void; + maximized: boolean; + onToggleMaximize: () => void; + windowControls?: boolean; + /** True on an AI-debug session (ADR 0254): a debugger MCP ask then offers + * no Always allow (the daemon never learns it) and says so. */ + debugSession?: boolean; +}) { + // A PresentPlan ask opens as the full-height plan review instead. + if (isPlanAsk(approval.toolName)) { + return ( + + ); + } + const toolName = askToolName(approval); + // The header shows the humanized `Server · Tool` for an MCP tool; + // destructiveness is judged on both forms (the inline card does the same). + const displayName = toolDisplayName(toolName); + const destructive = + DELETE_WORDS.test(toolName) || DELETE_WORDS.test(displayName); + const child = approval.child === true; + // A debugger MCP call (ADR 0254): approved one call at a time, Always + // allow never learned by the daemon — withheld here as on the inline card. + const debugMcp = isDebugMcpMutationAsk(approval, debugSession); + const offerAlways = !child && !debugMcp; + const respond = (choice: ApprovalChoice) => { + onRespond(choice); + onClose(); + }; + return ( + +
+
+

+ {child + ? `A subagent's ${toolName} needs your approval.` + : approval.description} +

+ {debugMcp && ( +

+ {DEBUG_MCP_ASK_NOTE} +

+ )} + +
+ +
+
+ ); +} diff --git a/studio/src/app/workspace/chat/_components/approval-panel.diff-header.test.tsx b/studio/src/app/workspace/chat/_components/approval-panel.diff-header.test.tsx new file mode 100644 index 0000000000..7d565f7b66 --- /dev/null +++ b/studio/src/app/workspace/chat/_components/approval-panel.diff-header.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { ApprovalRequest } from "@/features/agent"; +import { ApprovalPanel } from "./approval-panel"; +import { WRITE_OVERWRITE_NOTE } from "./tool-diff"; + +/** + * The approval gate carries the TUI's size signal and risk caveat: an Edit + * ask's path line says `-N +N` (and `replace all`), a Write ask's says + * `N lines · overwrites if it exists` — so the reader knows the blast + * radius before scrolling the diff or the file body. + */ +describe("ApprovalPanel diff headers", () => { + it("shows -N +N and replace all on an Edit ask", () => { + const ask: ApprovalRequest = { + approvalId: "s1:1:c1:r1", + sessionId: "s1", + toolName: "Edit", + description: "Edit needs your approval.", + details: "", + reason: "edits a file", + args: JSON.stringify({ + path: "src/a.ts", + old_string: "one\ntwo", + new_string: "one\n2\nthree", + replace_all: true, + }), + }; + render( {}} />); + expect(screen.getByText("src/a.ts")).toBeInTheDocument(); + expect(screen.getByText(/-1 \+2 · replace all/)).toBeInTheDocument(); + }); + + it("shows the line count and the overwrite caveat on a Write ask", () => { + const ask: ApprovalRequest = { + approvalId: "s1:1:c2:r1", + sessionId: "s1", + toolName: "Write", + description: "Write needs your approval.", + details: "", + reason: "writes a file", + args: JSON.stringify({ path: "notes.md", content: "a\nb\nc" }), + }; + render( {}} />); + expect(screen.getByText("notes.md")).toBeInTheDocument(); + expect( + screen.getByText(new RegExp(`3 lines · ${WRITE_OVERWRITE_NOTE}`)), + ).toBeInTheDocument(); + }); +}); diff --git a/studio/src/app/workspace/chat/_components/approval-panel.test.tsx b/studio/src/app/workspace/chat/_components/approval-panel.test.tsx new file mode 100644 index 0000000000..6d29b4692c --- /dev/null +++ b/studio/src/app/workspace/chat/_components/approval-panel.test.tsx @@ -0,0 +1,326 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ApprovalRequest } from "@/features/agent"; +import { ApprovalPanel } from "./approval-panel"; + +const mainAsk: ApprovalRequest = { + approvalId: "s1:1:c1:r1", + sessionId: "s1", + toolName: "Bash", + description: "Bash needs your approval.", + details: "ls -la", +}; + +const childAsk: ApprovalRequest = { + ...mainAsk, + approvalId: "subagent-x:1:c1:r1", + child: true, +}; + +describe("ApprovalPanel keyboard verdicts", () => { + // The TUI's approval modal keys, on the inline card: focus lands on Allow + // once when the ask arrives, ←/→ cycle the buttons, y/w/n answer, and the + // buttons show their keys. + const keyDown = (key: string) => + fireEvent.keyDown(document.activeElement ?? document.body, { key }); + + it("focuses Allow once when a new ask lands", () => { + render( {}} />); + expect(screen.getByRole("button", { name: "Allow once" })).toHaveFocus(); + }); + + it("cycles → over Allow once, Always allow, Deny and wraps", () => { + render( {}} />); + keyDown("ArrowRight"); + expect(screen.getByRole("button", { name: "Always allow" })).toHaveFocus(); + keyDown("ArrowRight"); + expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus(); + keyDown("ArrowRight"); + expect(screen.getByRole("button", { name: "Allow once" })).toHaveFocus(); + keyDown("ArrowLeft"); + expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus(); + }); + + it("answers y → once, w → always, n → deny from the focused card", () => { + const onRespond = vi.fn(); + render(); + keyDown("y"); + keyDown("w"); + keyDown("n"); + expect(onRespond.mock.calls.map(([choice]) => choice)).toEqual([ + "once", + "always", + "deny", + ]); + }); + + it("ignores w on a child ask and renders no W hint", () => { + const onRespond = vi.fn(); + render(); + keyDown("w"); + expect(onRespond).not.toHaveBeenCalled(); + expect( + screen.getAllByTestId("verdict-key").map((k) => k.textContent), + ).toEqual(["Y", "N"]); + keyDown("ArrowRight"); + expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus(); + }); + + it("shows the Y / W / N keycaps without changing the buttons' names", () => { + render( {}} />); + expect( + screen.getAllByTestId("verdict-key").map((k) => k.textContent), + ).toEqual(["Y", "W", "N"]); + expect(screen.getByRole("button", { name: "Allow once" })).toHaveAttribute( + "aria-keyshortcuts", + "y a", + ); + expect(screen.getByRole("button", { name: "Deny" })).toHaveAttribute( + "aria-keyshortcuts", + "n d", + ); + }); +}); + +describe("ApprovalPanel", () => { + it("shows the head's place in the queue when more than one ask is waiting", () => { + render( + {}} + queuePosition={{ index: 1, total: 2 }} + />, + ); + expect(screen.getByText("1 of 2")).toBeInTheDocument(); + expect(screen.getByLabelText("Request 1 of 2")).toBeInTheDocument(); + }); + + it("shows no queue badge for a lone ask", () => { + render( + {}} + queuePosition={{ index: 1, total: 1 }} + />, + ); + expect(screen.queryByText(/\bof 1\b/)).toBeNull(); + expect(screen.queryByText("Subagent")).toBeNull(); + }); + + it("offers all three verdicts for a main-agent ask and routes each to its choice", () => { + const onRespond = vi.fn(); + render(); + expect(screen.getByText("Bash needs your approval.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Allow once" })); + fireEvent.click(screen.getByRole("button", { name: "Always allow" })); + fireEvent.click(screen.getByRole("button", { name: "Deny" })); + expect(onRespond.mock.calls.map(([choice]) => choice)).toEqual([ + "once", + "always", + "deny", + ]); + }); + + it("withholds Always allow for a child ask and names the subagent", () => { + const onRespond = vi.fn(); + render(); + expect( + screen.getByText("A subagent's Bash needs your approval."), + ).toBeInTheDocument(); + expect(screen.getByText("Subagent")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull(); + expect(screen.getByRole("button", { name: "Allow once" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Deny" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Deny" })); + expect(onRespond).toHaveBeenCalledWith("deny"); + }); + + it("renders a legacy ask (no raw tier) as its joined details, verbatim", () => { + render( {}} />); + expect(screen.getByText("ls -la")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Raw" })).toBeNull(); + }); + + it("shows a Shell ask as the bare command with its timeout, not the flattened line", () => { + const shellAsk: ApprovalRequest = { + ...mainAsk, + toolName: "Shell", + description: "Shell needs your approval.", + reason: "runs a command", + args: JSON.stringify({ command: "go test ./...", timeout_ms: 120000 }), + details: "runs a command\n\ncommand: go test ./... · timeout_ms: 120000", + }; + render( {}} />); + expect(screen.getByText("runs a command")).toBeInTheDocument(); + expect(screen.getByText("go test ./...")).toBeInTheDocument(); + expect(screen.getByText("timeout 120s")).toBeInTheDocument(); + expect(screen.queryByText(/command: go test/)).toBeNull(); + }); + + it("renders an Edit ask as a diff with + and - rows", () => { + const editAsk: ApprovalRequest = { + ...mainAsk, + toolName: "Edit", + description: "Edit needs your approval.", + reason: "", + args: JSON.stringify({ + path: "main.go", + old_string: "a\nold line\nc", + new_string: "a\nnew line\nc", + }), + details: "", + }; + const { container } = render( + {}} />, + ); + expect(screen.getByText("main.go")).toBeInTheDocument(); + const dels = container.querySelectorAll('[data-diff="del"]'); + const adds = container.querySelectorAll('[data-diff="add"]'); + expect(dels).toHaveLength(1); + expect(adds).toHaveLength(1); + expect(dels[0]).toHaveTextContent("old line"); + expect(adds[0]).toHaveTextContent("new line"); + }); + + it("toggles to the verbatim args string with Raw and back with Pretty", () => { + const args = JSON.stringify({ command: "ls -la" }); + const shellAsk: ApprovalRequest = { + ...mainAsk, + toolName: "Shell", + reason: "runs a command", + args, + details: "runs a command\n\ncommand: ls -la", + }; + render( {}} />); + const toggle = screen.getByRole("button", { name: "Raw" }); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(toggle); + expect(screen.getByText(args)).toBeInTheDocument(); + expect(screen.getByText("Raw arguments")).toBeInTheDocument(); + const back = screen.getByRole("button", { name: "Pretty" }); + expect(back).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(back); + expect(screen.queryByText(args)).toBeNull(); + expect(screen.getByText("ls -la")).toBeInTheDocument(); + }); + + it("offers Expand only when a handler is wired, and passes the ask", () => { + const { rerender } = render( + {}} />, + ); + expect( + screen.queryByRole("button", { name: "Expand permission details" }), + ).toBeNull(); + const onExpand = vi.fn(); + rerender( + {}} + onExpand={onExpand} + />, + ); + fireEvent.click( + screen.getByRole("button", { name: "Expand permission details" }), + ); + expect(onExpand).toHaveBeenCalledWith(mainAsk); + }); + + // A debugger MCP call in an AI-debug session (ADR 0254): the daemon forces + // every such call through a fresh ask and never learns Always allow, so + // the card withholds the button and says why. + const debugMcpAsk: ApprovalRequest = { + ...mainAsk, + approvalId: "dbg-1:1:c1:r1", + sessionId: "dbg-1", + toolName: "mcp__github__create_issue", + description: "mcp__github__create_issue needs your approval.", + reason: "debug MCP call requires fresh current operator approval", + args: JSON.stringify({ title: "Flaky scheduler test" }), + details: "", + }; + + it("withholds Always allow for a debugger MCP ask in a debug session and explains the one-call rule", () => { + const onRespond = vi.fn(); + render( + , + ); + expect(screen.getByText("Debugger MCP")).toBeInTheDocument(); + expect(screen.getByTestId("debug-mcp-ask-note")).toHaveTextContent( + "Always allow is not learned", + ); + expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Allow once" })); + fireEvent.click(screen.getByRole("button", { name: "Deny" })); + expect(onRespond.mock.calls.map(([choice]) => choice)).toEqual([ + "once", + "deny", + ]); + }); + + it("keeps the generic card for the same ask outside a debug session, and for a non-MCP ask inside one", () => { + const { unmount } = render( + {}} />, + ); + expect(screen.getByRole("button", { name: "Always allow" })).toBeEnabled(); + expect(screen.queryByTestId("debug-mcp-ask-note")).toBeNull(); + unmount(); + render( + {}} debugSession />, + ); + expect(screen.getByRole("button", { name: "Always allow" })).toBeEnabled(); + expect(screen.queryByText("Debugger MCP")).toBeNull(); + }); +}); + +/** + * An MCP ask's badge reads the TUI's humanized `Server · Tool` while the + * exact daemon id stays on hover; destructiveness is judged on both forms, + * so an MCP delete tool still paints the card red. + */ +describe("ApprovalPanel MCP tool titles", () => { + const mcpAsk = (toolName: string): ApprovalRequest => ({ + ...mainAsk, + toolName, + description: `${toolName} needs your approval.`, + details: "", + }); + + it("names an MCP tool Server · Tool on the badge and keeps the exact id on hover", () => { + render( + {}} + />, + ); + const badge = screen.getByText("GitHub · Issue write"); + expect(badge).toHaveAttribute("title", "mcp__github__issue_write"); + expect( + screen.queryByText("This action modifies or deletes data."), + ).toBeNull(); + }); + + it("still classifies an MCP delete tool as destructive", () => { + render( + {}} + />, + ); + expect(screen.getByText("GitHub · Delete branch")).toHaveAttribute( + "title", + "mcp__github__delete_branch", + ); + expect( + screen.getByText("This action modifies or deletes data."), + ).toBeInTheDocument(); + }); + + it("leaves a core tool's badge unchanged", () => { + render( {}} />); + expect(screen.getByText("Bash")).toHaveAttribute("title", "Bash"); + }); +}); diff --git a/studio/src/app/workspace/chat/_components/approval-panel.tsx b/studio/src/app/workspace/chat/_components/approval-panel.tsx new file mode 100644 index 0000000000..7997c9ad09 --- /dev/null +++ b/studio/src/app/workspace/chat/_components/approval-panel.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { Fullscreen, ShieldAlert } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { ApprovalChoice, ApprovalRequest } from "@/features/agent"; +import { + DEBUG_MCP_ASK_NOTE, + isDebugMcpMutationAsk, +} from "@/features/agent/approval-queue"; +import { isPlanAsk } from "@/features/agent/plan-ask"; +import { toolDisplayName } from "@/lib/tool-names"; +import { cn } from "@/lib/utils"; +import { ApprovalVerdictBar } from "./approval-verdict-bar"; +import { AskArgsView } from "./ask-args-view"; +import { PlanReviewCard } from "./plan-review-panel"; + +const DELETE_WORDS = /\b(delete|remove|drop|revoke|destroy|purge|rm)\b/i; + +/** + * The daemon's verdict is three-way (allow_once / allow_always / deny), so the + * panel offers exactly those scopes — no "for session" button, which would + * promise a grant scope the backend does not model. + */ +export function ApprovalPanel({ + approval, + onRespond, + queuePosition, + onExpand, + debugSession = false, +}: { + approval: ApprovalRequest; + onRespond: (choice: ApprovalChoice) => void; + /** This ask's place in the FIFO queue (1-based). The "1 of N" badge shows + * only while more than one ask is waiting. */ + queuePosition?: { index: number; total: number }; + /** Opens the ask's full-height view (the side panel); omitted = the card + * offers no Expand button (the thread panel keeps it inline). */ + onExpand?: (approval: ApprovalRequest) => void; + /** True on an AI-debug session (ADR 0254): a debugger MCP ask then offers + * no Always allow (the daemon never learns it) and says so. */ + debugSession?: boolean; +}) { + // A PresentPlan ask is a plan review, not a tool authorization: its own + // surface (rendered plan, approve & run / auto-accept edits / iterate). + if (isPlanAsk(approval.toolName)) { + return ( + + ); + } + // ONE ask = ONE tool call. The pill names the tool; the args belong in the + // preview block below, never badge-ified (a Write ask's args are a whole + // file). Destructiveness is judged on the tool name alone — scanning file + // CONTENT for the word "delete" painted harmless writes red. + const toolName = + approval.toolName || + approval.description.replace(/ needs your approval\.?$/i, "").trim() || + "Tool"; + // The badge shows the TUI's humanized `Server · Tool` for an MCP tool + // (exact id on hover); destructiveness is judged on BOTH forms, because + // `\b` never fires inside `mcp__github__delete_branch`. + const displayName = toolDisplayName(toolName); + const destructive = + DELETE_WORDS.test(toolName) || DELETE_WORDS.test(displayName); + // A child's ask names who is asking. It offers no "Always allow": a + // persistent grant learned from a throwaway child would outlive it (the + // TUI withholds AllowAlways for child asks for the same reason). + const child = approval.child === true; + // A debugger MCP call (ADR 0254) is approved one call at a time: the + // daemon never learns Always allow for it, so the button is withheld and + // the card says why instead of offering a grant that would not persist. + const debugMcp = isDebugMcpMutationAsk(approval, debugSession); + const offerAlways = !child && !debugMcp; + const description = child + ? `A subagent's ${toolName} needs your approval.` + : approval.description; + const queued = + queuePosition && queuePosition.total > 1 ? queuePosition : null; + + return ( +
+
+ + + Permission required + + {queued && ( + + {`${queued.index} of ${queued.total}`} + + )} + {onExpand && ( + + )} +
+

{description}

+
+ + {displayName} + + {child && ( + + Subagent + + )} + {debugMcp && ( + + Debugger MCP + + )} +
+ {destructive && ( +

+ This action modifies or deletes data. +

+ )} + {debugMcp && ( +

+ {DEBUG_MCP_ASK_NOTE} +

+ )} + + +
+ ); +} diff --git a/studio/src/app/workspace/chat/_components/approval-verdict-bar.test.tsx b/studio/src/app/workspace/chat/_components/approval-verdict-bar.test.tsx new file mode 100644 index 0000000000..47b5132d6a --- /dev/null +++ b/studio/src/app/workspace/chat/_components/approval-verdict-bar.test.tsx @@ -0,0 +1,224 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { KEYMAP_STORAGE_KEY } from "@/lib/shortcuts/keymap"; +import { ShortcutsProvider, useShortcut } from "@/lib/shortcuts/use-shortcuts"; +import { memoryStorage } from "@/test/memory-storage"; +import { ApprovalVerdictBar } from "./approval-verdict-bar"; + +/** + * The keyboard half of the verdict buttons (the TUI's approval modal foot): + * focus lands on Allow once when a new ask arrives, ← / → cycle the visible + * buttons with wrap, the registry's verdict keys and Esc answer the ask from + * inside the bar (marked consumed, so the global dispatcher never doubles + * it), and each button shows its key as an aria-hidden keycap. + */ + +const ask = { approvalId: "s1:1:c1:r1" }; + +const button = (name: string) => screen.getByRole("button", { name }); +const keyDown = (key: string, init: KeyboardEventInit = {}) => + fireEvent.keyDown(document.activeElement ?? document.body, { key, ...init }); + +afterEach(() => { + (document.activeElement as HTMLElement | null)?.blur(); +}); + +describe("ApprovalVerdictBar", () => { + it("focuses Allow once when the ask lands, and moves to the next ask's button too", () => { + const { rerender } = render( + {}} offerAlways />, + ); + expect(button("Allow once")).toHaveFocus(); + button("Deny").focus(); + rerender( + {}} + offerAlways + />, + ); + expect(button("Allow once")).toHaveFocus(); + }); + + it("never steals focus from a text field the operator is typing in", () => { + render(