diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02c45e7c..0c7fb572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,10 @@ jobs: # Use production build for faster startup NODE_ENV: production + - name: Run component tests + working-directory: ./ui + run: pnpm run test:ct + - name: Upload Playwright report uses: actions/upload-artifact@v4 if: failure() diff --git a/models/src/agent_control_models/controls.py b/models/src/agent_control_models/controls.py index a043622b..6b7f0201 100644 --- a/models/src/agent_control_models/controls.py +++ b/models/src/agent_control_models/controls.py @@ -73,6 +73,7 @@ class ControlScope(BaseModel): "Step types this control applies to (omit to apply to all types). " "Built-in types are 'tool' and 'llm'." ), + examples=[["llm"], ["tool"], ["llm", "tool"]], ) step_names: list[str] | None = Field( default=None, @@ -171,6 +172,7 @@ class EvaluatorSpec(BaseModel): name: str = Field( ..., + min_length=1, description="Evaluator name or agent-scoped reference (agent:evaluator)", examples=["regex", "list", "my-agent:pii-detector"], ) @@ -183,6 +185,17 @@ class EvaluatorSpec(BaseModel): ], ) + @field_validator("name", mode="before") + @classmethod + def validate_name(cls, value: str) -> str: + if not isinstance(value, str): + return value + + normalized = value.strip() + if not normalized: + raise ValueError("Evaluator name cannot be empty or whitespace-only.") + return normalized + @model_validator(mode="after") def validate_evaluator_config(self) -> Self: """Validate config against evaluator's schema if evaluator is registered. diff --git a/models/src/agent_control_models/server.py b/models/src/agent_control_models/server.py index bf388707..e139f7d1 100644 --- a/models/src/agent_control_models/server.py +++ b/models/src/agent_control_models/server.py @@ -293,6 +293,16 @@ class GetControlDataResponse(BaseModel): data: ControlDefinition = Field(description="Control data payload") +class GetControlSchemaResponse(BaseModel): + model_config = {"populate_by_name": True} + + schema_: dict[str, Any] = Field( + alias="schema", + serialization_alias="schema", + description="JSON Schema for a full ControlDefinition payload", + ) + + class SetControlDataRequest(BaseModel): """Request to update control configuration data.""" data: ControlDefinition = Field( diff --git a/sdks/typescript/overlays/method-names.overlay.yaml b/sdks/typescript/overlays/method-names.overlay.yaml index 7a9c7a03..7071ae2b 100644 --- a/sdks/typescript/overlays/method-names.overlay.yaml +++ b/sdks/typescript/overlays/method-names.overlay.yaml @@ -110,6 +110,11 @@ actions: x-speakeasy-group: controls x-speakeasy-name-override: create + - target: $["paths"]["/api/v1/controls/schema"]["get"] + update: + x-speakeasy-group: controls + x-speakeasy-name-override: getSchema + - target: $["paths"]["/api/v1/controls/validate"]["post"] update: x-speakeasy-group: controls diff --git a/sdks/typescript/src/generated/funcs/controls-get-schema.ts b/sdks/typescript/src/generated/funcs/controls-get-schema.ts new file mode 100644 index 00000000..ca5442bd --- /dev/null +++ b/sdks/typescript/src/generated/funcs/controls-get-schema.ts @@ -0,0 +1,142 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import { AgentControlSDKCore } from "../core.js"; +import * as M from "../lib/matchers.js"; +import { compactMap } from "../lib/primitives.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js"; +import { pathToFunc } from "../lib/url.js"; +import { AgentControlSDKError } from "../models/errors/agent-control-sdk-error.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/errors/http-client-errors.js"; +import { ResponseValidationError } from "../models/errors/response-validation-error.js"; +import { SDKValidationError } from "../models/errors/sdk-validation-error.js"; +import * as models from "../models/index.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Get control definition JSON schema + * + * @remarks + * Return the canonical JSON schema for ControlDefinition. + */ +export function controlsGetSchema( + client: AgentControlSDKCore, + options?: RequestOptions, +): APIPromise< + Result< + models.GetControlSchemaResponse, + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + options, + )); +} + +async function $do( + client: AgentControlSDKCore, + options?: RequestOptions, +): Promise< + [ + Result< + models.GetControlSchemaResponse, + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const path = pathToFunc("/api/v1/controls/schema")(); + + const headers = new Headers(compactMap({ + Accept: "application/json", + })); + + const secConfig = await extractSecurity(client._options.apiKeyHeader); + const securityInput = secConfig == null ? {} : { apiKeyHeader: secConfig }; + const requestSecurity = resolveGlobalSecurity(securityInput); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? "", + operationID: "get_control_schema_api_v1_controls_schema_get", + oAuth2Scopes: null, + + resolvedSecurity: requestSecurity, + + securitySource: client._options.apiKeyHeader, + retryConfig: options?.retries + || client._options.retryConfig + || { strategy: "none" }, + retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"], + }; + + const requestRes = client._createRequest(context, { + security: requestSecurity, + method: "GET", + baseURL: options?.serverURL, + path: path, + headers: headers, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, options); + if (!requestRes.ok) { + return [requestRes, { status: "invalid" }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + errorCodes: ["4XX", "5XX"], + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: "request-error", request: req }]; + } + const response = doResult.value; + + const [result] = await M.match< + models.GetControlSchemaResponse, + | AgentControlSDKError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.GetControlSchemaResponse$inboundSchema), + M.fail("4XX"), + M.fail("5XX"), + )(response, req); + if (!result.ok) { + return [result, { status: "complete", request: req, response }]; + } + + return [result, { status: "complete", request: req, response }]; +} diff --git a/sdks/typescript/src/generated/models/get-control-schema-response.ts b/sdks/typescript/src/generated/models/get-control-schema-response.ts new file mode 100644 index 00000000..b2acafd4 --- /dev/null +++ b/sdks/typescript/src/generated/models/get-control-schema-response.ts @@ -0,0 +1,33 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { safeParse } from "../lib/schemas.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import { SDKValidationError } from "./errors/sdk-validation-error.js"; + +export type GetControlSchemaResponse = { + /** + * JSON Schema for a full ControlDefinition payload + */ + schema: { [k: string]: any }; +}; + +/** @internal */ +export const GetControlSchemaResponse$inboundSchema: z.ZodMiniType< + GetControlSchemaResponse, + unknown +> = z.object({ + schema: z.record(z.string(), z.any()), +}); + +export function getControlSchemaResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetControlSchemaResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetControlSchemaResponse' from JSON`, + ); +} diff --git a/sdks/typescript/src/generated/models/index.ts b/sdks/typescript/src/generated/models/index.ts index aba5b19f..32624c43 100644 --- a/sdks/typescript/src/generated/models/index.ts +++ b/sdks/typescript/src/generated/models/index.ts @@ -47,6 +47,7 @@ export * from "./get-agent-policies-response.js"; export * from "./get-agent-response.js"; export * from "./get-control-data-response.js"; export * from "./get-control-response.js"; +export * from "./get-control-schema-response.js"; export * from "./get-policy-controls-response.js"; export * from "./get-policy-response.js"; export * from "./health-response.js"; diff --git a/sdks/typescript/src/generated/sdk/controls.ts b/sdks/typescript/src/generated/sdk/controls.ts index 67583335..52b91c30 100644 --- a/sdks/typescript/src/generated/sdk/controls.ts +++ b/sdks/typescript/src/generated/sdk/controls.ts @@ -5,6 +5,7 @@ import { controlsCreate } from "../funcs/controls-create.js"; import { controlsDelete } from "../funcs/controls-delete.js"; import { controlsGetData } from "../funcs/controls-get-data.js"; +import { controlsGetSchema } from "../funcs/controls-get-schema.js"; import { controlsGet } from "../funcs/controls-get.js"; import { controlsList } from "../funcs/controls-list.js"; import { controlsUpdateData } from "../funcs/controls-update-data.js"; @@ -83,6 +84,21 @@ export class Controls extends ClientSDK { )); } + /** + * Get control definition JSON schema + * + * @remarks + * Return the canonical JSON schema for ControlDefinition. + */ + async getSchema( + options?: RequestOptions, + ): Promise { + return unwrapAsync(controlsGetSchema( + this, + options, + )); + } + /** * Validate control configuration * diff --git a/server/Makefile b/server/Makefile index 16c7b23f..e7eccc2e 100644 --- a/server/Makefile +++ b/server/Makefile @@ -71,5 +71,8 @@ start-dependencies: test: $(TEST_DB_ENV) uv run --package agent-control-server pytest --cov=src --cov-report=xml:../coverage-server.xml -q -run: start-dependencies migrate +run: start-dependencies migrate run-server + +run-server: uv run --package agent-control-server uvicorn agent_control_server.main:app --reload --host $(AGENT_CONTROL_HOST) --port $(AGENT_CONTROL_PORT) + diff --git a/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index 4f83df96..f28e42a2 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -11,6 +11,7 @@ DeleteControlResponse, GetControlDataResponse, GetControlResponse, + GetControlSchemaResponse, ListControlsResponse, PaginationInfo, PatchControlRequest, @@ -99,7 +100,11 @@ def _serialize_control_definition(control_def: ControlDefinition) -> dict[str, o async def _validate_control_definition( control_def: ControlDefinition, db: AsyncSession ) -> None: - """Validate evaluator config for a control definition.""" + """Validate evaluator config for definitions referencing known global evaluators. + + Agent-scoped evaluators must exist on the referenced agent. Builtin and external + names that are not loaded in this process are accepted without config checks. + """ available_evaluators = list_evaluators() agent_data_by_name: dict[str, AgentData] = {} for field_prefix, leaf in _iter_condition_leaves(control_def.condition): @@ -211,6 +216,9 @@ async def _validate_control_definition( evaluator_cls = available_evaluators.get(parsed.name) if evaluator_cls is None: + # Global (builtin / external) evaluators may be absent from this runtime + # (optional packages, forward compatibility). Store the definition without + # config validation; evaluation will fail later if the evaluator is missing. continue try: @@ -325,6 +333,19 @@ async def create_control( return CreateControlResponse(control_id=control.id) +@router.get( + "/schema", + response_model=GetControlSchemaResponse, + summary="Get control definition JSON schema", + response_description="JSON schema for ControlDefinition", +) +async def get_control_schema() -> GetControlSchemaResponse: + """Return the canonical JSON schema for ControlDefinition.""" + return GetControlSchemaResponse( + schema=ControlDefinition.model_json_schema(by_alias=True) + ) + + @router.get( "/{control_id}", response_model=GetControlResponse, diff --git a/server/tests/test_controls.py b/server/tests/test_controls.py index bc20f5da..87b364cb 100644 --- a/server/tests/test_controls.py +++ b/server/tests/test_controls.py @@ -2,11 +2,10 @@ from copy import deepcopy from typing import Any +from agent_control_server.models import Control from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from agent_control_server.models import Control - from .conftest import engine @@ -106,6 +105,20 @@ def test_get_control_data_initially_unconfigured(client: TestClient) -> None: assert "invalid data" in response_data.get("detail", "").lower() +def test_get_control_schema_returns_control_definition_schema(client: TestClient) -> None: + resp = client.get("/api/v1/controls/schema") + + assert resp.status_code == 200, resp.text + schema = resp.json()["schema"] + properties = schema["properties"] + + assert schema["type"] == "object" + assert {"execution", "condition", "action"}.issubset(properties) + assert set(schema["required"]) >= {"execution", "condition", "action"} + assert "condition" in properties + assert "$defs" in schema + + VALID_CONTROL_DATA = { "description": "Test Control", "enabled": True, diff --git a/server/tests/test_controls_validation.py b/server/tests/test_controls_validation.py index 2761bdd3..cb8b765c 100644 --- a/server/tests/test_controls_validation.py +++ b/server/tests/test_controls_validation.py @@ -163,6 +163,47 @@ def test_validation_empty_string_path_rejected(client: TestClient): assert any("empty string" in e.get("message", "") for e in errors) +def test_validation_empty_evaluator_name_rejected(client: TestClient): + """Test that empty evaluator names are rejected at the request boundary.""" + control_id = create_control(client) + payload = deepcopy(VALID_CONTROL_PAYLOAD) + payload["condition"]["evaluator"] = {"name": "", "config": {"pattern": "x"}} + + resp = client.put(f"/api/v1/controls/{control_id}/data", json={"data": payload}) + + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert any( + "evaluator.name" in str(err.get("field", "")) + for err in body.get("errors", []) + ) + assert any( + "empty or whitespace-only" in err.get("message", "").lower() + for err in body.get("errors", []) + ) + + +def test_validate_endpoint_whitespace_evaluator_name_rejected(client: TestClient): + """Whitespace-only evaluator names are rejected during validate-without-save too.""" + payload = deepcopy(VALID_CONTROL_PAYLOAD) + payload["condition"]["evaluator"] = {"name": " ", "config": {"pattern": "x"}} + + resp = client.post("/api/v1/controls/validate", json={"data": payload}) + + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert any( + "evaluator.name" in str(err.get("field", "")) + for err in body.get("errors", []) + ) + assert any( + "empty or whitespace-only" in err.get("message", "").lower() + for err in body.get("errors", []) + ) + + def test_validation_none_path_defaults_to_star(client: TestClient): """Test that None/missing path defaults to '*'.""" # Given: a control and payload without path in selector (None) @@ -318,3 +359,32 @@ def test_validation_nested_agent_scoped_evaluator_error_uses_bracketed_field_pat and err.get("code") == "evaluator_not_found" for err in body.get("errors", []) ) + + +def test_validation_standalone_evaluator_error_uses_bracketed_field_path( + client: TestClient, +): + """Nested standalone (global) evaluator config errors use bracketed leaf paths.""" + control_id = create_control(client) + payload = deepcopy(VALID_CONTROL_PAYLOAD) + payload["condition"] = { + "or": [ + { + "selector": {"path": "input"}, + "evaluator": { + "name": "regex", + "config": {}, + }, + } + ] + } + + resp = client.put(f"/api/v1/controls/{control_id}/data", json={"data": payload}) + + assert resp.status_code == 422 + body = resp.json() + assert body["error_code"] == "VALIDATION_ERROR" + assert any( + err.get("field", "").startswith("data.condition.or[0].evaluator") + for err in body.get("errors", []) + ) diff --git a/ui/.gitignore b/ui/.gitignore index c8fc9c98..b066e039 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -42,3 +42,5 @@ next-env.d.ts CLAUDE.md .claude + +playwright/.cache/ \ No newline at end of file diff --git a/ui/package.json b/ui/package.json index 73680bba..a9f29044 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,8 @@ "typecheck": "tsc --noEmit", "fetch-api-types": "openapi-typescript http://localhost:8000/openapi.json -o src/core/api/generated/api-types.ts", "test:integration": "playwright test", + "test:ct": "playwright test -c playwright-ct.config.ts", + "test:ct:ui": "playwright test -c playwright-ct.config.ts --ui", "test:integration:ui": "playwright test --ui", "test:integration:headed": "playwright test --headed", "test:integration:debug": "playwright test --debug", @@ -31,6 +33,8 @@ "typecheck": "Run TypeScript checks (no emit)", "fetch-api-types": "Regenerate API types from the server OpenAPI schema at localhost:8000", "test:integration": "Run Playwright integration tests", + "test:ct": "Run Playwright component tests (JsonEditor, etc.)", + "test:ct:ui": "Run component tests in interactive UI mode", "test:integration:ui": "Run Playwright integration tests in interactive UI mode", "test:integration:headed": "Run Playwright integration tests in headed browser mode", "test:integration:debug": "Run Playwright integration tests in debug mode", @@ -39,7 +43,14 @@ "prettify:check": "Check formatting with Prettier without changing files" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", "@emotion/is-prop-valid": "^1.4.0", + "@lezer/highlight": "^1.2.3", "@mantine/charts": "^7.17.8", "@mantine/code-highlight": "7.17.5", "@mantine/core": "7.17.5", @@ -49,16 +60,23 @@ "@mantine/hooks": "7.17.5", "@mantine/modals": "7.17.7", "@mantine/notifications": "7.17.7", + "@monaco-editor/react": "^4.7.0", "@rungalileo/icons": "^0.0.1", "@rungalileo/jupiter-ds": "^0.0.8", "@tabler/icons-react": "3.31", "@tanstack/react-query": "5.74.4", "@tanstack/react-query-devtools": "5.72.2", "@tanstack/react-table": "8.20.5", + "@uiw/codemirror-extensions-basic-setup": "^4.25.9", + "@uiw/codemirror-themes": "^4.25.9", + "@uiw/codemirror-themes-all": "4.25.9", + "@uiw/react-codemirror": "^4.25.9", "axios": "1.12.0", "classix": "2.2.0", "date-fns": "4.1.0", "json-edit-react": "^1.29.0", + "jsonc-parser": "^3.3.1", + "monaco-editor": "^0.55.1", "motion": "^12.33.0", "next": "15.4.10", "openapi-fetch": "0.14.0", @@ -68,11 +86,13 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", + "@playwright/experimental-ct-react": "1.57.0", "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^9", "eslint-config-next": "16.1.1", "eslint-plugin-simple-import-sort": "^12.1.1", @@ -84,6 +104,7 @@ "prettier": "^3.4.2", "tailwindcss": "^4", "typescript": "^5", - "typescript-eslint": "^8.32.1" + "typescript-eslint": "^8.32.1", + "vite": "^6.4.1" } } diff --git a/ui/playwright-ct.config.ts b/ui/playwright-ct.config.ts new file mode 100644 index 00000000..f57d4c7f --- /dev/null +++ b/ui/playwright-ct.config.ts @@ -0,0 +1,35 @@ +import path from 'node:path'; + +import { defineConfig, devices } from '@playwright/experimental-ct-react'; + +/** + * Component tests: mount React in-browser via Vite (no Next.js server). + * @see https://playwright.dev/docs/test-components + */ +export default defineConfig({ + testDir: './tests/ct', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [['html', { open: 'never' }], ['list']], + + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure', + ctViteConfig: { + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 60b2ac66..f0c73169 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -7,6 +7,8 @@ import { defineConfig, devices } from '@playwright/test'; */ export default defineConfig({ testDir: './tests', + /* Component tests use playwright-ct.config.ts (Vite + mount). */ + testIgnore: '**/ct/**', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/ui/playwright/index.html b/ui/playwright/index.html new file mode 100644 index 00000000..9b3a31c6 --- /dev/null +++ b/ui/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Playwright CT + + +
+ + + diff --git a/ui/playwright/index.tsx b/ui/playwright/index.tsx new file mode 100644 index 00000000..a9b87f09 --- /dev/null +++ b/ui/playwright/index.tsx @@ -0,0 +1,28 @@ +import '@mantine/charts/styles.css'; +import '@mantine/code-highlight/styles.css'; +import '@mantine/core/styles.css'; +import '@mantine/dates/styles.css'; +import '@mantine/notifications/styles.css'; +import '@rungalileo/icons/styles.css'; +import '@rungalileo/jupiter-ds/styles.css'; +import '@/styles/globals.css'; + +import { MantineProvider } from '@mantine/core'; +import { DatesProvider } from '@mantine/dates'; +import { ModalsProvider } from '@mantine/modals'; +import { beforeMount } from '@playwright/experimental-ct-react/hooks'; +import { JupiterThemeProvider } from '@rungalileo/jupiter-ds'; + +import { appTheme } from '@/theme'; + +beforeMount(async ({ App }) => ( + + + + + + + + + +)); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 15095d63..af1cf499 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -8,9 +8,30 @@ importers: .: dependencies: + '@codemirror/autocomplete': + specifier: ^6.20.1 + version: 6.20.1 + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/language': + specifier: ^6.12.3 + version: 6.12.3 + '@codemirror/lint': + specifier: ^6.9.5 + version: 6.9.5 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.40.0 + version: 6.40.0 '@emotion/is-prop-valid': specifier: ^1.4.0 version: 1.4.0 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 '@mantine/charts': specifier: ^7.17.8 version: 7.17.8(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(recharts@2.15.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) @@ -38,6 +59,9 @@ importers: '@mantine/notifications': specifier: 7.17.7 version: 7.17.7(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) '@rungalileo/icons': specifier: ^0.0.1 version: 0.0.1(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -56,6 +80,18 @@ importers: '@tanstack/react-table': specifier: 8.20.5 version: 8.20.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@uiw/codemirror-extensions-basic-setup': + specifier: ^4.25.9 + version: 4.25.9(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-themes': + specifier: ^4.25.9 + version: 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-themes-all': + specifier: 4.25.9 + version: 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/react-codemirror': + specifier: ^4.25.9 + version: 4.25.9(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) axios: specifier: 1.12.0 version: 1.12.0 @@ -68,6 +104,12 @@ importers: json-edit-react: specifier: ^1.29.0 version: 1.29.0(react@19.1.4) + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 motion: specifier: ^12.33.0 version: 12.33.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -90,6 +132,9 @@ importers: '@eslint/eslintrc': specifier: ^3.3.3 version: 3.3.3 + '@playwright/experimental-ct-react': + specifier: 1.57.0 + version: 1.57.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6))(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6))) '@playwright/test': specifier: ^1.57.0 version: 1.57.0 @@ -105,6 +150,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6))) eslint: specifier: ^9 version: 9.39.2(jiti@2.6.1) @@ -141,6 +189,9 @@ importers: typescript-eslint: specifier: ^8.32.1 version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^6.4.1 + version: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6)) packages: @@ -182,6 +233,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -203,6 +258,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -219,6 +286,33 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@codemirror/autocomplete@6.20.1': + resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} + + '@codemirror/search@6.6.0': + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.40.0': + resolution: {integrity: sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -234,6 +328,162 @@ packages: '@emotion/memoize@0.9.0': resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -339,89 +589,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -462,6 +728,18 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + '@mantine/charts@7.17.8': resolution: {integrity: sha512-lzDa2JM0uD2X32vnUPtERJc4V5nYkrbpOpnC/G3p0Kkwcxh9v59p5uMDxHXoHcv/OsMPALKYWBkY9aGWvD/E4g==} peerDependencies: @@ -534,6 +812,19 @@ packages: peerDependencies: react: ^18.x || ^19.x + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -560,24 +851,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.4.8': resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.4.8': resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.4.8': resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.4.8': resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==} @@ -607,6 +902,15 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@playwright/experimental-ct-core@1.57.0': + resolution: {integrity: sha512-Z5Uh+61vR5FDRE+YJIMrnD8m6i2wJmYK525AHCJNcAcGcEC+i7xuMnZmZkg+booi3YHIwql/ApAlm03+jsCIzQ==} + engines: {node: '>=18'} + + '@playwright/experimental-ct-react@1.57.0': + resolution: {integrity: sha512-wNRmkLOxHEXA9OL7QggNYVHnqaGlMOTB5q9FhrnlcFHHRs+M8SH9mQy5//dGFoYKAkhuZf4GPA3poi9bBdkdfQ==} + engines: {node: '>=18'} + hasBin: true + '@playwright/test@1.57.0': resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} engines: {node: '>=18'} @@ -622,6 +926,147 @@ packages: resolution: {integrity: sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -691,24 +1136,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -772,6 +1221,18 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -819,6 +1280,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.51.0': resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -878,6 +1342,143 @@ packages: resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uiw/codemirror-extensions-basic-setup@4.25.9': + resolution: {integrity: sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==} + peerDependencies: + '@codemirror/autocomplete': '>=6.0.0' + '@codemirror/commands': '>=6.0.0' + '@codemirror/language': '>=6.0.0' + '@codemirror/lint': '>=6.0.0' + '@codemirror/search': '>=6.0.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + + '@uiw/codemirror-theme-abcdef@4.25.9': + resolution: {integrity: sha512-F6bZcm20N3r4ZeCMdyjjII/fYHqE17sbRk6pFWfU+NPxe522A/uaRKpEaBK/iDwYqpKZgI3XUz7j3KcYzA99Mg==} + + '@uiw/codemirror-theme-abyss@4.25.9': + resolution: {integrity: sha512-zcMHX3abHsaV+IRhnHeWA5aYTP/9HTk/MR5Zh3pfwASv8YMsQlcjBva8vEZULV9pJDferW/9GXbKbbPdmceJeg==} + + '@uiw/codemirror-theme-androidstudio@4.25.9': + resolution: {integrity: sha512-HPIWpEC9ElhpJ2NZUKB6z+eStQzFDrkIGW9pTJxYHSCv2Los7FgD/R6eGqjTS4LVlBf9FR+KU/5E6dLT8DQHlw==} + + '@uiw/codemirror-theme-andromeda@4.25.9': + resolution: {integrity: sha512-JSqK8/sVFbFfTyv/okaT4c8suulf9zasqd4YBuTSkPZo+Sd/50blxMSVe5IWwDSiW5hkiupb7FC2IP1siHhncw==} + + '@uiw/codemirror-theme-atomone@4.25.9': + resolution: {integrity: sha512-EXG/+p+Y9j/StU2yAtz/+JZj/8WaSGqwjsad79CSBgpHrSU0ERzv4urYWXgEmLTKKkFimwTigy7qOJlLAwkN2A==} + + '@uiw/codemirror-theme-aura@4.25.9': + resolution: {integrity: sha512-cJyInS81wh0lWYs1XDiyFSxCCXrJ+4qifBsDHSYELdLgbnr441T3Kr6a9lyUobtL4DZVaIaCKE9rajrFdJIeAw==} + + '@uiw/codemirror-theme-basic@4.25.9': + resolution: {integrity: sha512-40x+anangMmPziZSeEcg6P5YDLn7fF1ioS5VxEPXMGUTbikv0au4PXVNsf7CtP0VwO4MmGt87zZI6rQIexEP3w==} + + '@uiw/codemirror-theme-bbedit@4.25.9': + resolution: {integrity: sha512-SGXQ0tLsqcRvxXCrdeU/MiQ3liNKvr8DCxaSt4N5LP7EPGO94ebuvba0F+H/3LpeJJrn5Xq0FuhaPlMYJ10RXg==} + + '@uiw/codemirror-theme-bespin@4.25.9': + resolution: {integrity: sha512-Zr35B1FpM+VMIoHot397GP/dQBWkFz6SlFqf3JSX6wlwgy2d4ot3YF9fBglGkM3C3ITmkBBQRnlvELwke+dXBg==} + + '@uiw/codemirror-theme-console@4.25.9': + resolution: {integrity: sha512-vhN9QKStneKyiNzu+DuA5JOss9WfzecuDjvmEYApQL9zvRmNUAP6La0C2vpZCji1Y23OAFZUJvTU+eKbept3cw==} + + '@uiw/codemirror-theme-copilot@4.25.9': + resolution: {integrity: sha512-MLBXBEp+jDQC+BbFUQxxwsOKvhbCsIpIjwBgNfR4KKKQxD6tF6u+CE7ERcrRWJ6cCV2lDrs1IZRZGPQCSpHMIA==} + + '@uiw/codemirror-theme-darcula@4.25.9': + resolution: {integrity: sha512-lrex1DXg/mx2BX1UtnyFlat7w6c3RyE5GMvyR8uPfXNAXMUEKjYxNRdUuQ9WGlOMzQZ3x+UbKnUZd/r6AmXwsw==} + + '@uiw/codemirror-theme-dracula@4.25.9': + resolution: {integrity: sha512-0VTnpPCHPc+7LqYsQOX6nvW32XiiT+O6kJjReUbV7Eio3vPHsb+b9P4DKhz4AAvIIYMxmHkMuautHKuWktFXSg==} + + '@uiw/codemirror-theme-duotone@4.25.9': + resolution: {integrity: sha512-6IPZncdrtcgnU1EtQ1/IzaULZ+Jw5uAeVeQCae+rFBnW/m6Q8nWB8+iVnk8kCevgjT5ScZmRd9h4yqtSeJbUwQ==} + + '@uiw/codemirror-theme-eclipse@4.25.9': + resolution: {integrity: sha512-0pT0vRyLAotj5UjIZbHSmsZ8oz7l8IU5bhx5p7MDrTOdi73ZjyTsG4YsDzSXndERnfgkBbZJrlZiExBkXnhtUA==} + + '@uiw/codemirror-theme-github@4.25.9': + resolution: {integrity: sha512-AGpTamNiySKNzq3Jc7QjpwgQRVaHUaBtmOKiUDghYSfEGjsc5uW4NUW70sSU3BnkGv+lCTUnF3175KM24BWZbw==} + + '@uiw/codemirror-theme-gruvbox-dark@4.25.9': + resolution: {integrity: sha512-9qIa1z4zwubN2kHAs+lJvdrmMMMf69JeyVPAwSoNaImL8wUQ/J3291qcfuoZjv8RsqSzrKTgxqLHtkAhB7xcwg==} + + '@uiw/codemirror-theme-kimbie@4.25.9': + resolution: {integrity: sha512-zLjT7MkotuT07rx4ZPZOM1/H+sa+kCmJr5BDu2ASNpF7Sj4w0cTNcAyxKHj+N6LcgIM8PICxqB97CJhlurNTBA==} + + '@uiw/codemirror-theme-material@4.25.9': + resolution: {integrity: sha512-6f2x+gmj2hHagqy6VkpnPbK7SWyP6kKruGgqpyIy09/f9pAUCqkW8mRY5ZEr28tA+YEGQaSY0Z2IBCHl8OKJog==} + + '@uiw/codemirror-theme-monokai-dimmed@4.25.9': + resolution: {integrity: sha512-6/Z9tF4UFngaXifAKC4DI2l61G3rtcWOxvCwgs5zzNVMTciUI+Bl/K7eCvjf2y0LfLmK8Ovob8ODDBcVgwzp5g==} + + '@uiw/codemirror-theme-monokai@4.25.9': + resolution: {integrity: sha512-qKWRZOGpBCasZJdYU+SsXd92TjncF3QYHpraCPe29bxN22jeIxi2UC4MCuJHwa8hHljHOCSdx1XG/GuUMn7XiQ==} + + '@uiw/codemirror-theme-noctis-lilac@4.25.9': + resolution: {integrity: sha512-HXjQutWsVYfiBM6ze4SomXmSJNzYYJ/fUYJ3TJLhnp5cjIPNBsMsgOAaWp3L64xUqqorb0+1y6kdmUKxTEp6rQ==} + + '@uiw/codemirror-theme-nord@4.25.9': + resolution: {integrity: sha512-5c568xmMidwICADxACB1zIhKoEgqbdVrdeOUZ2p5pE6NNKGR4ATzk9OSqhvr1ZhZPNOktxqSLLRzihFaZG0bDQ==} + + '@uiw/codemirror-theme-okaidia@4.25.9': + resolution: {integrity: sha512-lIJFUs/ws0prQz+dVo5ZIp0o6vxW7p6nf8iRFETN5S3KA3nJUR2cTF6u8mYLFwHMrFs2eReRsFyH94wjmuPWvg==} + + '@uiw/codemirror-theme-quietlight@4.25.9': + resolution: {integrity: sha512-BWFcFb3WHTCVROkjExh/TMMTJ5SNcDafaVEIwneKypiHoTJoIY6RlSRBj6GA3O5IgKdrGmhje87s0Gx2OLIndg==} + + '@uiw/codemirror-theme-red@4.25.9': + resolution: {integrity: sha512-pSOs2ByCVGJXbABhfTEU4TlRh/Wa9BJlDUa219iq1jO3AUDUM/LIPNLhmQvMtOituMX8WKJprspBrDcveXsisg==} + + '@uiw/codemirror-theme-solarized@4.25.9': + resolution: {integrity: sha512-axUgU9+3JKXW83F+te454qcyTmQAm0+2Fxv0yoegiH6bdl7DjFq/lNVGGZtLwN47AQCj2Qwrheeet2t3GbY9VQ==} + + '@uiw/codemirror-theme-sublime@4.25.9': + resolution: {integrity: sha512-/Ha1K3P0sqFWrsYtCu6Uih/t8C73dVY6m5rObjCnnokr//kOusKwlwt1fJiEFdIcSKlH2WBIvW5tb75tcYitnw==} + + '@uiw/codemirror-theme-tokyo-night-day@4.25.9': + resolution: {integrity: sha512-1ziFletBO6tfRtX4FVWij1wYIf95uYi54dgnMz5CXe4A4u710rJ3uS3C4ijlnclRbwHjNTqtrMWNuicKDBMsPg==} + + '@uiw/codemirror-theme-tokyo-night-storm@4.25.9': + resolution: {integrity: sha512-qz8Vg+ze12TuLk+fqwx3oga3H6rDE+81PpKMGLfbI1BwPDgg7GZGTGrWZoN1Bpf6EV0dA4WO8K6lbzFhlS6S1Q==} + + '@uiw/codemirror-theme-tokyo-night@4.25.9': + resolution: {integrity: sha512-NkSqguMpzRjsRBbTIfOrGS35tQkE3K8AAetZHlbRZC7fnI52RreZ11X41cOYrc/Dapt8xqUPlhlvclymGFgy8g==} + + '@uiw/codemirror-theme-tomorrow-night-blue@4.25.9': + resolution: {integrity: sha512-iG2wCXO/rkJIrvW7rJY7Ehh4yushw8X4vQnstjArxofR6uNrE9ay3Ut7M0cxrwY7z8YIU5f7NQFODE/h3HNmVA==} + + '@uiw/codemirror-theme-vscode@4.25.9': + resolution: {integrity: sha512-9KTnScHTSk97yGnyNYvDm6QZuBCdbO1OzMQ5bHtoBSPSVtH0LjY3bS6CXsBagb22v8OLPx/XwrBYOjKFp409CQ==} + + '@uiw/codemirror-theme-white@4.25.9': + resolution: {integrity: sha512-75PHfVejBvgF1EbponpEOgND/T6MJYZ673aODPuR7mKPZNfn8649qOSrp7wvMN/NEZ+W5CxV3U7tb9MQWPcM4A==} + + '@uiw/codemirror-theme-xcode@4.25.9': + resolution: {integrity: sha512-sMiDpOiW0iiNsLyqL1Vx6wZKOSoVUNfmWbBDtaYzlkRcKzkyJQp68cPIq5VG8Mhl2z+PX5cPbOA0nZEegNLicA==} + + '@uiw/codemirror-themes-all@4.25.9': + resolution: {integrity: sha512-OVcGb6dkgJ8NgcHFvSQkRLHHIRswZhBKK0XZZzRVMxDnCIXfmnDfeChNoKjuzwBr+C0jS7UAAqrWbcqrLj3mhg==} + + '@uiw/codemirror-themes@4.25.9': + resolution: {integrity: sha512-DAHKb/L9ELwjY4nCf/MP/mIllHOn4GQe7RR4x8AMJuNeh9nGRRoo1uPxrxMmUL/bKqe6kDmDbIZ2AlhlqyIJuw==} + peerDependencies: + '@codemirror/language': '>=6.0.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + + '@uiw/react-codemirror@4.25.9': + resolution: {integrity: sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==} + peerDependencies: + '@babel/runtime': '>=7.11.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/theme-one-dark': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + codemirror: '>=6.0.0' + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] @@ -917,41 +1518,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -973,6 +1582,12 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1148,6 +1763,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -1174,6 +1792,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1312,6 +1933,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1361,6 +1985,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1601,6 +2230,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1914,6 +2548,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -1971,24 +2608,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -2046,6 +2687,11 @@ packages: resolution: {integrity: sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==} engines: {node: '>=4'} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2088,6 +2734,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + motion-dom@12.33.0: resolution: {integrity: sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==} @@ -2387,6 +3036,10 @@ packages: react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2493,6 +3146,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2586,6 +3244,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2625,6 +3286,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -2811,6 +3475,49 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2925,6 +3632,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -2940,6 +3649,16 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -2965,27 +3684,163 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@emnapi/core@1.8.1': - dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 + '@codemirror/autocomplete@6.20.1': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/json': 1.0.3 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.5': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + crelt: 1.0.6 + + '@codemirror/search@6.6.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.40.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 + '@esbuild/sunos-x64@0.25.12': optional: true - '@emnapi/wasi-threads@1.1.0': - dependencies: - tslib: 2.8.1 + '@esbuild/win32-arm64@0.25.12': optional: true - '@emotion/is-prop-valid@1.4.0': - dependencies: - '@emotion/memoize': 0.9.0 + '@esbuild/win32-ia32@0.25.12': + optional: true - '@emotion/memoize@0.9.0': {} + '@esbuild/win32-x64@0.25.12': + optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: @@ -3185,6 +4040,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.1': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + '@mantine/charts@7.17.8(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(recharts@2.15.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4))': dependencies: '@mantine/core': 7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -3263,6 +4134,19 @@ snapshots: dependencies: react: 19.1.4 + '@marijn/find-cluster-break@1.0.2': {} + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.1.4 + react-dom: 19.1.4(react@19.1.4) + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -3314,6 +4198,43 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@playwright/experimental-ct-core@1.57.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6))': + dependencies: + playwright: 1.57.0 + playwright-core: 1.57.0 + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6)) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + '@playwright/experimental-ct-react@1.57.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6))(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6)))': + dependencies: + '@playwright/experimental-ct-core': 1.57.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6)) + '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6))) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - vite + - yaml + '@playwright/test@1.57.0': dependencies: playwright: 1.57.0 @@ -3341,6 +4262,83 @@ snapshots: transitivePeerDependencies: - supports-color + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + '@rtsao/scc@1.1.0': {} '@rungalileo/icons@0.0.1(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': @@ -3467,6 +4465,27 @@ snapshots: tslib: 2.8.1 optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -3509,6 +4528,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3600,6 +4622,362 @@ snapshots: '@typescript-eslint/types': 8.51.0 eslint-visitor-keys: 4.2.1 + '@uiw/codemirror-extensions-basic-setup@4.25.9(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + + '@uiw/codemirror-theme-abcdef@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-abyss@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-androidstudio@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-andromeda@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-atomone@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-aura@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-basic@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-bbedit@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-bespin@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-console@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-copilot@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-darcula@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-dracula@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-duotone@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-eclipse@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-github@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-gruvbox-dark@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-kimbie@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-material@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-monokai-dimmed@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-monokai@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-noctis-lilac@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-nord@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-okaidia@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-quietlight@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-red@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-solarized@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-sublime@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-tokyo-night-day@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-tokyo-night-storm@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-tokyo-night@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-tomorrow-night-blue@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-vscode@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-white@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-xcode@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-themes-all@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@uiw/codemirror-theme-abcdef': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-abyss': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-androidstudio': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-andromeda': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-atomone': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-aura': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-basic': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-bbedit': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-bespin': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-console': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-copilot': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-darcula': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-dracula': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-duotone': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-eclipse': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-github': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-gruvbox-dark': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-kimbie': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-material': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-monokai': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-monokai-dimmed': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-noctis-lilac': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-nord': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-okaidia': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-quietlight': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-red': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-solarized': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-sublime': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-tokyo-night': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-tokyo-night-day': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-tokyo-night-storm': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-tomorrow-night-blue': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-vscode': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-white': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-theme-xcode': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-themes': 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-themes@4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + + '@uiw/react-codemirror@4.25.9(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + dependencies: + '@babel/runtime': 7.28.4 + '@codemirror/commands': 6.10.3 + '@codemirror/state': 6.6.0 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.40.0 + '@uiw/codemirror-extensions-basic-setup': 4.25.9(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + codemirror: 6.0.2 + react: 19.1.4 + react-dom: 19.1.4(react@19.1.4) + transitivePeerDependencies: + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -3659,6 +5037,18 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6)))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6)) + transitivePeerDependencies: + - supports-color + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -3856,6 +5246,16 @@ snapshots: clsx@2.1.1: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -3878,6 +5278,8 @@ snapshots: convert-source-map@2.0.0: {} + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4002,6 +5404,10 @@ snapshots: '@babel/runtime': 7.28.4 csstype: 3.2.3 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4122,6 +5528,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -4428,6 +5863,9 @@ snapshots: fsevents@2.3.2: optional: true + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -4723,6 +6161,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -4837,6 +6277,8 @@ snapshots: map-obj@2.0.0: {} + marked@14.0.0: {} + math-intrinsics@1.1.0: {} meow@4.0.1: @@ -4883,6 +6325,11 @@ snapshots: minimist@1.2.8: {} + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + motion-dom@12.33.0: dependencies: motion-utils: 12.29.2 @@ -5169,6 +6616,8 @@ snapshots: react: 19.1.4 react-dom: 19.1.4(react@19.1.4) + react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.1.4): dependencies: react: 19.1.4 @@ -5297,6 +6746,37 @@ snapshots: reusify@1.1.0: {} + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5436,6 +6916,8 @@ snapshots: stable-hash@0.0.5: {} + state-local@1.0.7: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5497,6 +6979,8 @@ snapshots: strip-json-comments@3.1.1: {} + style-mod@4.1.3: {} + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.1.4): dependencies: client-only: 0.0.1 @@ -5707,6 +7191,23 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@4.0.1(postcss@8.5.6)): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.27 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + sugarss: 4.0.1(postcss@8.5.6) + + w3c-keyname@2.2.8: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/ui/src/components/json-editor-codemirror/codemirror-theme-presets.ts b/ui/src/components/json-editor-codemirror/codemirror-theme-presets.ts new file mode 100644 index 00000000..1f6c7550 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/codemirror-theme-presets.ts @@ -0,0 +1,157 @@ +import { + defaultHighlightStyle, + syntaxHighlighting, +} from '@codemirror/language'; +import { type Extension } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { + atomone, + darcula, + dracula, + eclipse, + githubDark, + githubLight, + gruvboxDark, + gruvboxLight, + monokai, + nord, + quietlight, + solarizedDark, + solarizedLight, + tokyoNight, + tokyoNightDay, + tokyoNightStorm, + vscodeDark, + vscodeLight, + whiteLight, +} from '@uiw/codemirror-themes-all'; + +export const CODE_MIRROR_THEME_STORAGE_KEY = + 'agent-control.jsonEditor.cmTheme.v1'; + +export const DEFAULT_DARK_THEME_ID = 'vscode-dark'; +export const DEFAULT_LIGHT_THEME_ID = 'mantine-light'; + +const LIGHT_CHROME_THEME = EditorView.theme({ + '&': { + backgroundColor: 'var(--mantine-color-body)', + color: 'var(--mantine-color-text)', + }, + '.cm-gutters': { + backgroundColor: 'var(--mantine-color-body)', + borderRightColor: 'var(--mantine-color-body)', + color: 'var(--mantine-color-dimmed)', + }, + '.cm-content': { + caretColor: 'var(--mantine-color-text)', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--mantine-color-text)', + }, +}); + +/** Light preset matching Mantine surface colors + default token palette. */ +export const mantineLightCodeMirrorTheme: Extension[] = [ + LIGHT_CHROME_THEME, + syntaxHighlighting(defaultHighlightStyle), +]; + +export type CodeMirrorThemePreset = { + label: string; + extension: Extension | Extension[]; +}; + +export const CODE_MIRROR_DARK_THEME_PRESETS: Record< + string, + CodeMirrorThemePreset +> = { + [DEFAULT_DARK_THEME_ID]: { + label: 'VS Code Dark', + extension: vscodeDark, + }, + 'github-dark': { label: 'GitHub Dark', extension: githubDark }, + 'tokyo-night': { label: 'Tokyo Night', extension: tokyoNight }, + 'tokyo-night-storm': { + label: 'Tokyo Night Storm', + extension: tokyoNightStorm, + }, + nord: { label: 'Nord', extension: nord }, + dracula: { label: 'Dracula', extension: dracula }, + monokai: { label: 'Monokai', extension: monokai }, + 'gruvbox-dark': { label: 'Gruvbox Dark', extension: gruvboxDark }, + darcula: { label: 'Darcula', extension: darcula }, + 'atom-one': { label: 'Atom One', extension: atomone }, + 'solarized-dark': { label: 'Solarized Dark', extension: solarizedDark }, +}; + +export const CODE_MIRROR_LIGHT_THEME_PRESETS: Record< + string, + CodeMirrorThemePreset +> = { + [DEFAULT_LIGHT_THEME_ID]: { + label: 'Mantine (match app)', + extension: mantineLightCodeMirrorTheme, + }, + 'vscode-light': { label: 'VS Code Light', extension: vscodeLight }, + 'github-light': { label: 'GitHub Light', extension: githubLight }, + 'tokyo-night-day': { label: 'Tokyo Night Day', extension: tokyoNightDay }, + 'quiet-light': { label: 'Quiet Light', extension: quietlight }, + eclipse: { label: 'Eclipse', extension: eclipse }, + white: { label: 'White', extension: whiteLight }, + 'gruvbox-light': { label: 'Gruvbox Light', extension: gruvboxLight }, + 'solarized-light': { label: 'Solarized Light', extension: solarizedLight }, +}; + +export type StoredCodeMirrorThemePrefs = { + dark: string; + light: string; +}; + +export function readStoredCodeMirrorThemePrefs(): StoredCodeMirrorThemePrefs { + const fallback: StoredCodeMirrorThemePrefs = { + dark: DEFAULT_DARK_THEME_ID, + light: DEFAULT_LIGHT_THEME_ID, + }; + if (typeof window === 'undefined') { + return fallback; + } + try { + const raw = window.localStorage.getItem(CODE_MIRROR_THEME_STORAGE_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as Partial; + return { + dark: + parsed.dark && + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_DARK_THEME_PRESETS, + parsed.dark + ) + ? parsed.dark + : DEFAULT_DARK_THEME_ID, + light: + parsed.light && + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_LIGHT_THEME_PRESETS, + parsed.light + ) + ? parsed.light + : DEFAULT_LIGHT_THEME_ID, + }; + } catch { + return fallback; + } +} + +export function writeStoredCodeMirrorThemePrefs( + prefs: StoredCodeMirrorThemePrefs +): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem( + CODE_MIRROR_THEME_STORAGE_KEY, + JSON.stringify(prefs) + ); + } catch { + /* ignore quota / private mode */ + } +} diff --git a/ui/src/components/json-editor-codemirror/harness-schema.ts b/ui/src/components/json-editor-codemirror/harness-schema.ts new file mode 100644 index 00000000..dab4b662 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/harness-schema.ts @@ -0,0 +1,129 @@ +import type { JsonSchema } from '@/core/page-components/agent-detail/modals/edit-control/types'; + +/** + * Control JSON Schema used by the Playwright harness for JsonEditorCodeMirror. + * Aligned with mock control schema in ui/tests/fixtures.ts. + */ +export const HARNESS_CONTROL_SCHEMA: JsonSchema = { + $defs: { + ControlSelector: { + type: 'object', + properties: { + path: { + anyOf: [{ type: 'string' }, { type: 'null' }], + default: '*', + examples: ['output', 'context.user_id', '*'], + }, + }, + }, + EvaluatorSpec: { + type: 'object', + required: ['name', 'config'], + properties: { + name: { + type: 'string', + examples: ['regex', 'list'], + }, + config: { + type: 'object', + additionalProperties: true, + }, + }, + }, + ConditionNode: { + type: 'object', + properties: { + selector: { + anyOf: [{ $ref: '#/$defs/ControlSelector' }, { type: 'null' }], + }, + evaluator: { + anyOf: [{ $ref: '#/$defs/EvaluatorSpec' }, { type: 'null' }], + }, + and: { + anyOf: [ + { type: 'array', items: { $ref: '#/$defs/ConditionNode' } }, + { type: 'null' }, + ], + }, + or: { + anyOf: [ + { type: 'array', items: { $ref: '#/$defs/ConditionNode' } }, + { type: 'null' }, + ], + }, + not: { + anyOf: [{ $ref: '#/$defs/ConditionNode' }, { type: 'null' }], + }, + }, + }, + ControlScope: { + type: 'object', + properties: { + step_types: { + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + }, + step_names: { + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + }, + step_name_regex: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + stages: { + anyOf: [ + { + type: 'array', + items: { type: 'string', enum: ['pre', 'post'] }, + }, + { type: 'null' }, + ], + }, + }, + }, + SteeringContext: { + type: 'object', + required: ['message'], + properties: { + message: { type: 'string' }, + }, + }, + ControlAction: { + type: 'object', + required: ['decision'], + properties: { + decision: { + type: 'string', + enum: ['allow', 'deny', 'steer', 'warn', 'log'], + }, + steering_context: { + anyOf: [{ $ref: '#/$defs/SteeringContext' }, { type: 'null' }], + }, + }, + }, + }, + type: 'object', + required: ['execution', 'condition', 'action'], + properties: { + description: { + anyOf: [{ type: 'string' }, { type: 'null' }], + }, + enabled: { type: 'boolean' }, + execution: { type: 'string', enum: ['server', 'sdk'] }, + scope: { + $ref: '#/$defs/ControlScope', + }, + condition: { + $ref: '#/$defs/ConditionNode', + }, + action: { + $ref: '#/$defs/ControlAction', + }, + tags: { + type: 'array', + items: { type: 'string' }, + }, + }, +}; diff --git a/ui/src/components/json-editor-codemirror/index.ts b/ui/src/components/json-editor-codemirror/index.ts new file mode 100644 index 00000000..3c5037a8 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/index.ts @@ -0,0 +1 @@ +export { JsonEditorCodeMirror } from './json-editor-codemirror'; diff --git a/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts b/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts new file mode 100644 index 00000000..d0d74e64 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts @@ -0,0 +1,18 @@ +export { + applyTextEdit, + buildCodeMirrorInlineServerValidationErrorsExtension, + buildCodeMirrorJsonExtensions, + buildCodeMirrorRefactorLightbulbExtension, + buildCodeMirrorStandaloneDebugExtensions, + canRenderInlineServerValidationError, + caretAfterPrettyJsonReplace, + computeAutoEdit, + extractEvaluatorNames, + fixJsonCommas, + getCodeMirrorCompletionItems, + normalizeOnBlur, + setInlineServerValidationErrorsEffect, + shouldTriggerEvaluatorNameCompletion, + triggerRefactorActionsDropdown, + tryFormat, +} from './language'; diff --git a/ui/src/components/json-editor-codemirror/json-editor-codemirror.playwright-story.tsx b/ui/src/components/json-editor-codemirror/json-editor-codemirror.playwright-story.tsx new file mode 100644 index 00000000..48ae896e --- /dev/null +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.playwright-story.tsx @@ -0,0 +1,106 @@ +import { Box, Button, Group } from '@mantine/core'; +import { useCallback, useEffect, useState } from 'react'; + +import type { + JsonEditorEvaluatorOption, + JsonEditorMode, +} from '@/core/page-components/agent-detail/modals/edit-control/types'; + +import { HARNESS_CONTROL_SCHEMA } from './harness-schema'; +import { JsonEditorCodeMirror } from './json-editor-codemirror'; + +/** `data-testid` on the editor root for `tests/json-editor-bridge.ts` helpers. */ +export const CT_JSON_EDITOR_TEST_ID = 'codemirror-json-editor-ct'; + +const DEFAULT_CONTROL_JSON = + '{"execution":"server","condition":{},"action":{"decision":"allow"}}'; + +const CT_EVALUATORS: JsonEditorEvaluatorOption[] = [ + { + id: 'regex', + label: 'Regex', + source: 'global', + configSchema: { + type: 'object', + properties: { + pattern: { type: 'string', default: '.*' }, + }, + required: ['pattern'], + }, + }, + { + id: 'json', + label: 'JSON', + source: 'global', + configSchema: { + type: 'object', + properties: { + json_schema: { type: 'object', additionalProperties: true }, + }, + }, + }, +]; + +/** Host for Playwright component tests only (see `tests/ct/json-editor-codemirror.spec.tsx`). */ +export function JsonEditorCodeMirrorCtHost({ mode }: { mode: JsonEditorMode }) { + const [jsonText, setJsonText] = useState(() => + mode === 'control' ? DEFAULT_CONTROL_JSON : '{}' + ); + const [jsonError, setJsonError] = useState(null); + + useEffect(() => { + queueMicrotask(() => { + setJsonText(mode === 'control' ? DEFAULT_CONTROL_JSON : '{}'); + setJsonError(null); + }); + }, [mode]); + + const handleJsonChange = useCallback((next: string) => { + setJsonText(next); + }, []); + + return ( + + + + {mode === 'control' ? ( + + ) : null} + + + + ); +} diff --git a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx new file mode 100644 index 00000000..f6f156fb --- /dev/null +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx @@ -0,0 +1,719 @@ +import { closeCompletion, startCompletion } from '@codemirror/autocomplete'; +import { json, jsonParseLinter } from '@codemirror/lang-json'; +import { type Diagnostic, linter, lintGutter } from '@codemirror/lint'; +import { EditorSelection, type Extension } from '@codemirror/state'; +import { EditorView, type ViewUpdate } from '@codemirror/view'; +import { + ActionIcon, + Box, + Group, + NativeSelect, + Text, + Tooltip, + useMantineColorScheme, +} from '@mantine/core'; +import { useClipboard, useDebouncedValue } from '@mantine/hooks'; +import { + IconClipboardCheck, + IconClipboardCopy, + IconCode, +} from '@tabler/icons-react'; +import { findNodeAtLocation, parseTree } from 'jsonc-parser'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { isApiError } from '@/core/api/errors'; +import type { ProblemDetail, StepSchema } from '@/core/api/types'; +import { LabelWithTooltip } from '@/core/components/label-with-tooltip'; +import { ApiErrorAlert } from '@/core/page-components/agent-detail/modals/edit-control/api-error-alert'; +import type { + JsonEditorEvaluatorOption, + JsonEditorMode, + JsonSchema, +} from '@/core/page-components/agent-detail/modals/edit-control/types'; + +import { + CODE_MIRROR_DARK_THEME_PRESETS, + CODE_MIRROR_LIGHT_THEME_PRESETS, + DEFAULT_DARK_THEME_ID, + DEFAULT_LIGHT_THEME_ID, + mantineLightCodeMirrorTheme, + readStoredCodeMirrorThemePrefs, + type StoredCodeMirrorThemePrefs, + writeStoredCodeMirrorThemePrefs, +} from './codemirror-theme-presets'; +import { + buildCodeMirrorInlineServerValidationErrorsExtension, + buildCodeMirrorJsonExtensions, + buildCodeMirrorStandaloneDebugExtensions, + canRenderInlineServerValidationError, + caretAfterPrettyJsonReplace, + computeAutoEdit, + extractEvaluatorNames, + fixJsonCommas, + getCodeMirrorCompletionItems, + setInlineServerValidationErrorsEffect, + tryFormat, +} from './json-editor-codemirror-language'; +import type { JsonEditorCodeMirrorContext } from './language/types'; + +type JsonEditorTestElement = HTMLDivElement & { + __getJsonEditorValue?: () => string; + __getJsonEditorLanguageId?: () => string | null; + __setJsonEditorValue?: (value: string) => void; + __isJsonEditorReady?: () => boolean; + __focusJsonEditorAt?: (lineNumber: number, column: number) => void; + __triggerJsonEditorSuggest?: () => void; + __getJsonEditorSuggestions?: ( + lineNumber: number, + column: number + ) => Array<{ label: string; detail?: string }>; +}; + +const DEFAULT_HEIGHT = 400; +const DEFAULT_LABEL = 'Configuration (JSON)'; +const DEFAULT_TOOLTIP = 'Raw JSON configuration'; +const DEFAULT_TEST_ID = 'raw-json-textarea'; +const DEFAULT_VALIDATE_DEBOUNCE_MS = 500; + +const DENSITY_THEME = EditorView.theme({ + '&': { + fontSize: '12px', + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace', + }, + '.cm-scroller': { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace', + lineHeight: '1.4', + }, +}); + +/** Default @codemirror/autocomplete uses maxHeight ~10em; long lists clip the last items. */ +const AUTOCOMPLETE_LIST_THEME = EditorView.theme({ + '.cm-tooltip.cm-tooltip-autocomplete > ul': { + maxHeight: 'min(24em, 55vh)', + scrollbarGutter: 'stable', + }, +}); + +type CodeMirrorComponentType = typeof import('@uiw/react-codemirror').default; + +export type JsonEditorCodeMirrorProps = { + jsonText: string; + handleJsonChange: (text: string) => void; + jsonError?: string | null; + setJsonError?: (error: string | null) => void; + validationError?: ProblemDetail | null; + setValidationError?: (error: ProblemDetail | null) => void; + onValidateConfig?: ( + config: Record, + options?: { signal?: AbortSignal } + ) => Promise; + onValidationStatusChange?: ( + status: 'idle' | 'validating' | 'valid' | 'invalid' + ) => void; + validateDebounceMs?: number; + height?: number; + label?: string; + tooltip?: string; + helperText?: React.ReactNode; + testId?: string; + editorMode?: JsonEditorMode; + schema?: JsonSchema | null; + evaluators?: JsonEditorEvaluatorOption[]; + activeEvaluatorId?: string | null; + steps?: StepSchema[]; + debugFlags?: { + enableBasicSetupExtension?: boolean; + enableAutoEdits?: boolean; + enableExternalSync?: boolean; + enableLintExtensions?: boolean; + useStandaloneCompletionSource?: boolean; + }; +}; + +export function JsonEditorCodeMirror({ + jsonText, + handleJsonChange, + jsonError, + validationError, + onValidateConfig, + onValidationStatusChange, + setJsonError, + setValidationError, + validateDebounceMs, + height = DEFAULT_HEIGHT, + label = DEFAULT_LABEL, + tooltip = DEFAULT_TOOLTIP, + helperText, + testId = DEFAULT_TEST_ID, + editorMode = 'evaluator-config', + schema, + evaluators, + activeEvaluatorId, + steps, + debugFlags, +}: JsonEditorCodeMirrorProps) { + const [CodeMirrorComponent, setCodeMirrorComponent] = + useState(null); + const { colorScheme } = useMantineColorScheme(); + const isDarkMode = colorScheme === 'dark'; + const [cmThemePrefs, setCmThemePrefs] = useState( + () => readStoredCodeMirrorThemePrefs() + ); + const [isReady, setIsReady] = useState(false); + const [lintErrors, setLintErrors] = useState([]); + const editorViewRef = useRef(null); + const editorRootRef = useRef(null); + const internalChangeRef = useRef(false); + const autoEditInProgressRef = useRef(false); + const previousEvaluatorNamesRef = useRef>(new Map()); + const previousDecisionRef = useRef(null); + const clipboard = useClipboard({ timeout: 1500 }); + + const effectiveDebugFlags = { + enableBasicSetupExtension: true, + enableAutoEdits: true, + enableExternalSync: true, + enableLintExtensions: true, + useStandaloneCompletionSource: false, + ...debugFlags, + }; + + useEffect(() => { + const loadModules = async () => { + const codeMirrorModule = await import('@uiw/react-codemirror'); + setCodeMirrorComponent(() => codeMirrorModule.default); + }; + void loadModules(); + }, []); + + useEffect(() => { + setCmThemePrefs((prev) => { + const darkOk = + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_DARK_THEME_PRESETS, + prev.dark + ) || + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_LIGHT_THEME_PRESETS, + prev.dark + ); + const lightOk = + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_LIGHT_THEME_PRESETS, + prev.light + ) || + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_DARK_THEME_PRESETS, + prev.light + ); + if (darkOk && lightOk) return prev; + const next: StoredCodeMirrorThemePrefs = { + dark: darkOk ? prev.dark : DEFAULT_DARK_THEME_ID, + light: lightOk ? prev.light : DEFAULT_LIGHT_THEME_ID, + }; + writeStoredCodeMirrorThemePrefs(next); + return next; + }); + }, []); + + const domainExtensions = useMemo(() => { + if (effectiveDebugFlags.useStandaloneCompletionSource) { + return buildCodeMirrorStandaloneDebugExtensions(); + } + return buildCodeMirrorJsonExtensions({ + mode: editorMode, + schema, + evaluators, + activeEvaluatorId, + steps, + }); + }, [ + activeEvaluatorId, + editorMode, + effectiveDebugFlags.useStandaloneCompletionSource, + evaluators, + schema, + steps, + ]); + + const parseDecision = useCallback((text: string): string | null => { + const tree = parseTree(text); + if (!tree) return null; + const node = findNodeAtLocation(tree, ['action', 'decision']); + return typeof node?.value === 'string' ? node.value : null; + }, []); + + useEffect(() => { + previousEvaluatorNamesRef.current = extractEvaluatorNames(jsonText); + previousDecisionRef.current = parseDecision(jsonText); + }, [jsonText, parseDecision]); + + const handleAutoEdits = useCallback( + (update: ViewUpdate) => { + if (!effectiveDebugFlags.enableAutoEdits) return; + if (!update.docChanged) return; + if (autoEditInProgressRef.current) { + return; + } + + const view = update.view; + const text = view.state.doc.toString(); + const { edit, nextEvaluatorNames, nextDecision } = computeAutoEdit( + text, + previousEvaluatorNamesRef.current, + previousDecisionRef.current, + editorMode, + evaluators + ); + + previousEvaluatorNamesRef.current = nextEvaluatorNames; + previousDecisionRef.current = nextDecision; + + if (!edit) return; + + autoEditInProgressRef.current = true; + try { + view.dispatch({ + changes: { + from: edit.offset, + to: edit.offset + edit.length, + insert: edit.newText, + }, + }); + closeCompletion(view); + + let nextText = view.state.doc.toString(); + // `JSON.stringify(..., 2)` for new config starts at column 0; re-format the + // whole document so nesting matches the editor (same as the Prettify action). + const commaFixed = fixJsonCommas(nextText); + const formatted = tryFormat(commaFixed); + const pretty = + formatted && formatted !== nextText ? formatted : commaFixed; + if (pretty !== nextText) { + const caretBeforeFormat = view.state.selection.main.head; + const mappedCaret = caretAfterPrettyJsonReplace( + nextText, + caretBeforeFormat, + pretty + ); + view.dispatch({ + changes: { from: 0, to: nextText.length, insert: pretty }, + selection: + mappedCaret != null + ? EditorSelection.single(mappedCaret) + : undefined, + scrollIntoView: true, + }); + nextText = view.state.doc.toString(); + } + + previousEvaluatorNamesRef.current = extractEvaluatorNames(nextText); + previousDecisionRef.current = parseDecision(nextText); + internalChangeRef.current = true; + handleJsonChange(nextText); + } finally { + autoEditInProgressRef.current = false; + } + }, + [ + editorMode, + evaluators, + handleJsonChange, + parseDecision, + effectiveDebugFlags.enableAutoEdits, + ] + ); + + const inlineServerValidationExtension = useMemo( + () => buildCodeMirrorInlineServerValidationErrorsExtension(), + [] + ); + + const extensions = useMemo( + () => [ + json(), + ...(effectiveDebugFlags.enableLintExtensions + ? [linter(jsonParseLinter()), lintGutter()] + : []), + DENSITY_THEME, + ...domainExtensions, + AUTOCOMPLETE_LIST_THEME, + EditorView.updateListener.of(handleAutoEdits), + inlineServerValidationExtension, + ], + [ + domainExtensions, + effectiveDebugFlags.enableLintExtensions, + handleAutoEdits, + inlineServerValidationExtension, + ] + ); + + const completionContext = useMemo( + () => ({ + mode: editorMode, + schema, + evaluators, + activeEvaluatorId, + steps, + }), + [activeEvaluatorId, editorMode, evaluators, schema, steps] + ); + + useEffect(() => { + const root = editorRootRef.current; + if (!root) return; + + const lineColumnToPosition = ( + lineNumber: number, + column: number + ): number => { + const view = editorViewRef.current; + if (!view) return 0; + const doc = view.state.doc; + const ln = Math.min(Math.max(lineNumber, 1), doc.lines); + const line = doc.line(ln); + const col = Math.max(1, column); + return Math.min(line.from + (col - 1), line.to); + }; + + root.__getJsonEditorValue = () => + editorViewRef.current?.state.doc.toString() ?? ''; + root.__getJsonEditorLanguageId = () => 'json'; + root.__isJsonEditorReady = () => + Boolean(CodeMirrorComponent && isReady && editorViewRef.current); + root.__focusJsonEditorAt = (lineNumber, column) => { + const view = editorViewRef.current; + if (!view) return; + const pos = lineColumnToPosition(lineNumber, column); + view.dispatch({ + selection: EditorSelection.single(pos), + scrollIntoView: true, + }); + view.focus(); + }; + root.__setJsonEditorValue = (nextValue) => { + const view = editorViewRef.current; + if (!view) return; + internalChangeRef.current = true; + const len = view.state.doc.length; + view.dispatch({ + changes: { from: 0, to: len, insert: nextValue }, + }); + handleJsonChange(nextValue); + view.focus(); + }; + root.__triggerJsonEditorSuggest = () => { + const view = editorViewRef.current; + if (!view) return; + view.focus(); + void startCompletion(view); + }; + root.__getJsonEditorSuggestions = (lineNumber, column) => { + const view = editorViewRef.current; + if (!view) return []; + const pos = lineColumnToPosition(lineNumber, column); + const text = view.state.doc.toString(); + return getCodeMirrorCompletionItems(text, pos, completionContext); + }; + return () => { + delete root.__getJsonEditorValue; + delete root.__getJsonEditorLanguageId; + delete root.__isJsonEditorReady; + delete root.__focusJsonEditorAt; + delete root.__setJsonEditorValue; + delete root.__triggerJsonEditorSuggest; + delete root.__getJsonEditorSuggestions; + }; + }, [CodeMirrorComponent, completionContext, handleJsonChange, isReady]); + + const [debouncedJsonText] = useDebouncedValue( + jsonText, + validateDebounceMs ?? DEFAULT_VALIDATE_DEBOUNCE_MS + ); + + const validationAbortControllerRef = useRef(null); + + useEffect(() => { + if (!onValidateConfig) return; + if (!debouncedJsonText) { + setJsonError?.(null); + setValidationError?.(null); + onValidationStatusChange?.('idle'); + return; + } + + let parsed: Record; + try { + parsed = JSON.parse(debouncedJsonText) as Record; + } catch { + setJsonError?.('Invalid JSON'); + setValidationError?.(null); + onValidationStatusChange?.('invalid'); + return; + } + + validationAbortControllerRef.current?.abort(); + const controller = new AbortController(); + validationAbortControllerRef.current = controller; + + setJsonError?.(null); + onValidationStatusChange?.('validating'); + + onValidateConfig(parsed, { signal: controller.signal }) + .then(() => { + if (controller.signal.aborted) return; + setValidationError?.(null); + onValidationStatusChange?.('valid'); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + if (isApiError(error)) { + setValidationError?.(error.problemDetail); + onValidationStatusChange?.('invalid'); + return; + } + setJsonError?.('Validation failed.'); + setValidationError?.(null); + onValidationStatusChange?.('invalid'); + }); + + return () => controller.abort(); + }, [ + onValidateConfig, + debouncedJsonText, + onValidationStatusChange, + setJsonError, + setValidationError, + ]); + + const onEditorChange = useCallback( + (value: string) => { + internalChangeRef.current = true; + handleJsonChange(value); + }, + [handleJsonChange] + ); + + const formatJson = useCallback(() => { + const view = editorViewRef.current; + if (!view) return; + + const current = view.state.doc.toString(); + const commaFixed = fixJsonCommas(current); + const formatted = tryFormat(commaFixed); + + const next = formatted && formatted !== current ? formatted : commaFixed; + if (next === current) return; + + internalChangeRef.current = true; + view.dispatch({ + changes: { from: 0, to: current.length, insert: next }, + }); + }, []); + + // Keep this block to test parent->editor sync behavior. + useEffect(() => { + if (!effectiveDebugFlags.enableExternalSync) return; + const view = editorViewRef.current; + if (!view) return; + if (internalChangeRef.current) { + internalChangeRef.current = false; + return; + } + const currentDoc = view.state.doc.toString(); + if (currentDoc !== jsonText) { + view.dispatch({ + changes: { from: 0, to: currentDoc.length, insert: jsonText }, + }); + } + }, [effectiveDebugFlags.enableExternalSync, jsonText]); + + const handleLint = useCallback(({ view }: ViewUpdate) => { + const diagnostics: Diagnostic[] = jsonParseLinter()(view); + setLintErrors(diagnostics.map((d) => d.message)); + }, []); + + // Push latest server validation errors into a CodeMirror state field, + // avoiding a full editor reconfigure on each validation response. + useEffect(() => { + const view = editorViewRef.current; + if (!view) return; + + const errors = validationError?.errors ?? []; + view.dispatch({ + effects: setInlineServerValidationErrorsEffect.of({ errors }), + }); + }, [validationError]); + + useEffect(() => { + if (!validationError && lintErrors.length === 0) return; + }, [lintErrors, validationError]); + + const unmappedValidationErrors = useMemo(() => { + const errors = validationError?.errors ?? []; + return errors + .filter((error) => !canRenderInlineServerValidationError(jsonText, error)) + .map((e) => ({ field: e.field, message: e.message })); + }, [jsonText, validationError]); + + const codeMirrorTheme = useMemo(() => { + const selectedId = isDarkMode ? cmThemePrefs.dark : cmThemePrefs.light; + const selectedExtension = + CODE_MIRROR_DARK_THEME_PRESETS[selectedId]?.extension ?? + CODE_MIRROR_LIGHT_THEME_PRESETS[selectedId]?.extension ?? + (isDarkMode + ? CODE_MIRROR_DARK_THEME_PRESETS[DEFAULT_DARK_THEME_ID].extension + : mantineLightCodeMirrorTheme); + return selectedExtension; + }, [isDarkMode, cmThemePrefs.dark, cmThemePrefs.light]); + + const cmThemeSelectData = useMemo( + () => + [ + ...Object.entries(CODE_MIRROR_DARK_THEME_PRESETS), + ...Object.entries(CODE_MIRROR_LIGHT_THEME_PRESETS), + ].map(([value, { label: optionLabel }]) => ({ + value, + label: optionLabel, + })), + [] + ); + + const cmThemeSelectValue = useMemo(() => { + const raw = isDarkMode ? cmThemePrefs.dark : cmThemePrefs.light; + const inDark = Object.prototype.hasOwnProperty.call( + CODE_MIRROR_DARK_THEME_PRESETS, + raw + ); + const inLight = Object.prototype.hasOwnProperty.call( + CODE_MIRROR_LIGHT_THEME_PRESETS, + raw + ); + if (inDark || inLight) return raw; + return isDarkMode ? DEFAULT_DARK_THEME_ID : DEFAULT_LIGHT_THEME_ID; + }, [isDarkMode, cmThemePrefs.dark, cmThemePrefs.light]); + + return ( + + + + + { + const value = event.currentTarget.value; + setCmThemePrefs((prev) => { + const next: StoredCodeMirrorThemePrefs = isDarkMode + ? { ...prev, dark: value } + : { ...prev, light: value }; + writeStoredCodeMirrorThemePrefs(next); + return next; + }); + }} + /> + + + + + + + + clipboard.copy(jsonText)} + aria-label="Copy JSON to clipboard" + > + {clipboard.copied ? ( + + ) : ( + + )} + + + + + + + + {CodeMirrorComponent ? ( + { + editorViewRef.current = view; + setIsReady(true); + }} + /> + ) : ( + + + Loading CodeMirror... + + + )} + + + {jsonError ? ( + + {jsonError} + + ) : null} + {helperText ? ( + + {helperText} + + ) : null} + {validationError ? ( + unmappedValidationErrors.length > 0 ? ( + + + + ) : null + ) : null} + + {isReady ? 'ready' : 'not-ready'} + + + ); +} diff --git a/ui/src/components/json-editor-codemirror/language/auto-edits.ts b/ui/src/components/json-editor-codemirror/language/auto-edits.ts new file mode 100644 index 00000000..5b72ac76 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/auto-edits.ts @@ -0,0 +1,258 @@ +import { + findNodeAtLocation, + type Node as JsonNode, + parseTree, +} from 'jsonc-parser'; + +import type { + JsonEditorEvaluatorOption, + JsonEditorMode, +} from '@/core/page-components/agent-detail/modals/edit-control/types'; + +import { + asSchema, + getSchemaDefault, + getSchemaEnumValues, + getSchemaProperties, + getSchemaRequiredProperties, + getSchemaType, + normalizeSchema, +} from './schema'; +import type { JsonEditorTextEdit } from './types'; + +type EvaluatorNodeInfo = { + name: string; + nameNode: JsonNode; + configNode: JsonNode | undefined; +}; + +function collectEvaluatorNames( + node: JsonNode | undefined, + result: Map +) { + if (!node || node.type !== 'object' || !node.children) return; + + const evaluatorNode = findNodeAtLocation(node, ['evaluator']); + if (evaluatorNode?.type === 'object') { + const nameNode = findNodeAtLocation(evaluatorNode, ['name']); + const configNode = findNodeAtLocation(evaluatorNode, ['config']); + if (nameNode && typeof nameNode.value === 'string') { + result.set(`${nameNode.offset}`, { + name: nameNode.value, + nameNode, + configNode, + }); + } + } + + for (const key of ['and', 'or'] as const) { + const arrayNode = findNodeAtLocation(node, [key]); + if (arrayNode?.type === 'array' && arrayNode.children) { + for (const child of arrayNode.children) + collectEvaluatorNames(child, result); + } + } + + const notNode = findNodeAtLocation(node, ['not']); + if (notNode?.type === 'object') collectEvaluatorNames(notNode, result); +} + +export function extractEvaluatorNames(text: string): Map { + const tree = parseTree(text); + if (!tree) return new Map(); + const conditionNode = findNodeAtLocation(tree, ['condition']); + const result = new Map(); + collectEvaluatorNames(conditionNode, result); + const names = new Map(); + for (const [key, info] of result) names.set(key, info.name); + return names; +} + +function getDefaultValueForSchema( + propSchema: Record +): unknown { + const defaultValue = getSchemaDefault(propSchema); + if (defaultValue !== undefined) return defaultValue; + const enumValues = getSchemaEnumValues(propSchema); + if (enumValues.length > 0) return enumValues[0]; + switch (getSchemaType(propSchema)) { + case 'string': + return ''; + case 'number': + case 'integer': + return 0; + case 'boolean': + return false; + case 'array': + return []; + case 'object': + return {}; + default: + return null; + } +} + +function buildDefaultConfig(configSchema: unknown): Record { + const schema = asSchema(configSchema); + if (!schema) return {}; + const normalized = normalizeSchema(schema, schema); + if (!normalized) return {}; + const properties = getSchemaProperties(normalized); + const required = new Set(getSchemaRequiredProperties(normalized)); + const config: Record = {}; + for (const [name, raw] of Object.entries(properties)) { + const propSchema = normalizeSchema(raw, schema); + if (!propSchema) continue; + const explicitDefault = getSchemaDefault(propSchema); + if (required.has(name) || explicitDefault !== undefined) { + config[name] = getDefaultValueForSchema(propSchema); + } + } + return config; +} + +function findEvaluatorConfigEdit( + text: string, + previousNames: Map, + evaluators: JsonEditorEvaluatorOption[] | undefined +): JsonEditorTextEdit | null { + const tree = parseTree(text); + if (!tree) return null; + const conditionNode = findNodeAtLocation(tree, ['condition']); + const result = new Map(); + collectEvaluatorNames(conditionNode, result); + + for (const [key, { name, configNode, nameNode }] of result) { + const prevName = previousNames.get(key); + if (prevName === undefined || prevName === name) continue; + const evaluator = evaluators?.find((item) => item.id === name); + if (!evaluator) continue; + const configJson = JSON.stringify( + buildDefaultConfig(evaluator.configSchema), + null, + 2 + ); + if (configNode) { + return { + offset: configNode.offset, + length: configNode.length, + newText: configJson, + }; + } + const nameEnd = nameNode.offset + nameNode.length; + return { + offset: nameEnd, + length: 0, + newText: `,\n"config": ${configJson}`, + }; + } + return null; +} + +function findSteeringContextEdit( + text: string, + previousDecision: string | null +): JsonEditorTextEdit | null { + const tree = parseTree(text); + if (!tree) return null; + const decisionNode = findNodeAtLocation(tree, ['action', 'decision']); + if (!decisionNode || typeof decisionNode.value !== 'string') return null; + + const currentDecision = decisionNode.value; + if (currentDecision === previousDecision) return null; + + if (currentDecision === 'steer') { + const steeringNode = findNodeAtLocation(tree, [ + 'action', + 'steering_context', + ]); + if (!steeringNode) { + const decisionEnd = decisionNode.offset + decisionNode.length; + return { + offset: decisionEnd, + length: 0, + newText: `,\n"steering_context": {"message": "Please correct your response."}`, + }; + } + } else if (previousDecision === 'steer') { + const actionNode = findNodeAtLocation(tree, ['action']); + if (actionNode?.type === 'object' && actionNode.children) { + for (const prop of actionNode.children) { + const key = prop.children?.[0]; + if (key?.value === 'steering_context') { + let start = prop.offset; + while (start > 0 && /[\s,]/.test(text[start - 1] ?? '')) start -= 1; + return { + offset: start, + length: prop.offset + prop.length - start, + newText: '', + }; + } + } + } + } + return null; +} + +export function computeAutoEdit( + text: string, + previousEvaluatorNames: Map, + previousDecision: string | null, + mode: JsonEditorMode, + evaluators: JsonEditorEvaluatorOption[] | undefined +): { + edit: JsonEditorTextEdit | null; + editKind: 'evaluator-config' | 'steering-context' | null; + nextEvaluatorNames: Map; + nextDecision: string | null; +} { + const nextEvaluatorNames = extractEvaluatorNames(text); + let nextDecision: string | null = previousDecision; + try { + const tree = parseTree(text); + if (tree) { + const node = findNodeAtLocation(tree, ['action', 'decision']); + nextDecision = typeof node?.value === 'string' ? node.value : null; + } + } catch { + nextDecision = previousDecision; + } + + if (mode !== 'control') { + return { edit: null, editKind: null, nextEvaluatorNames, nextDecision }; + } + + const evaluatorEdit = findEvaluatorConfigEdit( + text, + previousEvaluatorNames, + evaluators + ); + if (evaluatorEdit) { + return { + edit: evaluatorEdit, + editKind: 'evaluator-config', + nextEvaluatorNames, + nextDecision, + }; + } + + const steeringEdit = findSteeringContextEdit(text, previousDecision); + if (steeringEdit) { + return { + edit: steeringEdit, + editKind: 'steering-context', + nextEvaluatorNames, + nextDecision, + }; + } + + return { edit: null, editKind: null, nextEvaluatorNames, nextDecision }; +} + +export function applyTextEdit(text: string, edit: JsonEditorTextEdit): string { + return ( + text.slice(0, edit.offset) + + edit.newText + + text.slice(edit.offset + edit.length) + ); +} diff --git a/ui/src/components/json-editor-codemirror/language/context.ts b/ui/src/components/json-editor-codemirror/language/context.ts new file mode 100644 index 00000000..295c611f --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/context.ts @@ -0,0 +1,151 @@ +import { + findNodeAtLocation, + type Node as JsonNode, + parseTree, +} from 'jsonc-parser'; + +import type { JsonEditorEvaluatorOption } from '@/core/page-components/agent-detail/modals/edit-control/types'; + +import { + asSchema, + getSchemaAtProperty, + getSchemaEnumValues, + normalizeSchema, +} from './schema'; +import type { + JsonEditorCodeMirrorContext, + JsonPath, + SchemaCursor, +} from './types'; + +export function isEvaluatorNameLocation(path: JsonPath): boolean { + return ( + path.length >= 2 && + path[path.length - 1] === 'name' && + path[path.length - 2] === 'evaluator' + ); +} + +export function isSelectorPathLocation(path: JsonPath): boolean { + return ( + path.length >= 2 && + path[path.length - 1] === 'path' && + path[path.length - 2] === 'selector' + ); +} + +export function getStringArrayAtPath( + tree: JsonNode | undefined, + path: JsonPath +): string[] { + const node = tree ? findNodeAtLocation(tree, path) : undefined; + if (!node || node.type !== 'array' || !node.children) return []; + return node.children + .map((child) => (typeof child.value === 'string' ? child.value : null)) + .filter((value): value is string => value !== null); +} + +export function getScopeFilters(tree: JsonNode | undefined): { + stepTypes: string[]; + stepNames: string[]; +} { + return { + stepTypes: getStringArrayAtPath(tree, ['scope', 'step_types']), + stepNames: getStringArrayAtPath(tree, ['scope', 'step_names']), + }; +} + +export function resolveActiveEvaluator( + context: JsonEditorCodeMirrorContext, + tree: JsonNode | undefined, + path: JsonPath +): JsonEditorEvaluatorOption | null { + if (context.mode === 'evaluator-config') { + return ( + context.evaluators?.find( + (item) => item.id === context.activeEvaluatorId + ) ?? null + ); + } + + const evaluatorIndex = path.lastIndexOf('evaluator'); + if (evaluatorIndex === -1 || !tree) return null; + const evaluatorPath = path.slice(0, evaluatorIndex + 1); + const nameNode = findNodeAtLocation(tree, [...evaluatorPath, 'name']); + const value = typeof nameNode?.value === 'string' ? nameNode.value : null; + if (!value) return null; + return context.evaluators?.find((item) => item.id === value) ?? null; +} + +/** + * True when `path[index]` is the `config` property of an `evaluator` object. + * Matches Monaco `isEvaluatorConfigSegment` — used to swap the schema root to + * the active evaluator's configSchema while editing control JSON. + */ +function isEvaluatorConfigSegment(path: JsonPath, index: number): boolean { + return ( + typeof path[index] === 'string' && + path[index] === 'config' && + index > 0 && + path[index - 1] === 'evaluator' + ); +} + +export function resolveSchemaAtJsonPath( + context: JsonEditorCodeMirrorContext, + activeEvaluator: JsonEditorEvaluatorOption | null, + path: JsonPath +): SchemaCursor { + const controlRoot = asSchema(context.schema) ?? null; + let rootSchema = controlRoot; + if (context.mode === 'evaluator-config' && activeEvaluator?.configSchema) { + rootSchema = asSchema(activeEvaluator.configSchema) ?? rootSchema; + } + if (!rootSchema) return { schema: null, rootSchema: null }; + + let cursor = normalizeSchema(rootSchema, rootSchema); + + for (let index = 0; index < path.length; index += 1) { + const segment = path[index]; + if (cursor === null) break; + + if (context.mode === 'control' && isEvaluatorConfigSegment(path, index)) { + const configRoot = asSchema(activeEvaluator?.configSchema ?? null); + if (configRoot) { + rootSchema = configRoot; + cursor = normalizeSchema(rootSchema, rootSchema); + continue; + } + } + + if (typeof segment === 'number') { + const normalized = normalizeSchema(cursor, rootSchema); + cursor = normalizeSchema(normalized?.items, rootSchema); + continue; + } + cursor = getSchemaAtProperty(cursor, segment, rootSchema); + } + return { schema: cursor, rootSchema }; +} + +export function getSchemaDescription( + schema: Record | null +): string | null { + return typeof schema?.description === 'string' ? schema.description : null; +} + +export function getSchemaTitle( + schema: Record | null +): string | null { + return typeof schema?.title === 'string' ? schema.title : null; +} + +export function parseJsonTree(text: string): JsonNode | undefined { + return parseTree(text) ?? undefined; +} + +export function getEnumValues( + schema: Record | null +): unknown[] { + return getSchemaEnumValues(schema); +} diff --git a/ui/src/components/json-editor-codemirror/language/extensions.ts b/ui/src/components/json-editor-codemirror/language/extensions.ts new file mode 100644 index 00000000..20482f62 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/extensions.ts @@ -0,0 +1,1171 @@ +import { + acceptCompletion, + autocompletion, + closeCompletion, + type Completion, + completionKeymap, + insertCompletionText, + moveCompletionSelection, + pickedCompletion, + snippetCompletion, + startCompletion, +} from '@codemirror/autocomplete'; +import { + type Extension, + Prec, + type Range, + RangeSetBuilder, +} from '@codemirror/state'; +import { + Decoration, + EditorView, + gutter, + GutterMarker, + hoverTooltip, + keymap, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from '@codemirror/view'; +import { + findNodeAtLocation, + findNodeAtOffset, + getLocation, + type Node as JsonNode, + parseTree, +} from 'jsonc-parser'; + +export { + buildCodeMirrorInlineServerValidationErrorsExtension, + canRenderInlineServerValidationError, + setInlineServerValidationErrorsEffect, +} from './inline-server-validation'; + +import { + getEnumValues, + getScopeFilters, + isEvaluatorNameLocation, + isSelectorPathLocation, + parseJsonTree, + resolveActiveEvaluator, + resolveSchemaAtJsonPath, +} from './context'; +import { + getJsonInsertTextForSchemaPropertyValue, + getSchemaAtProperty, + getSchemaDescription, + getSchemaProperties, + getSchemaTitle, + getSchemaType, + normalizeSchema, +} from './schema'; +import { + type JsonEditorCodeMirrorContext, + MAX_HINT_VALUES, + ROOT_SELECTOR_PATHS, +} from './types'; + +/** + * CodeMirror uses `state.sliceDoc(from, to)` as the fuzzy-filter query. + * Property-key contexts used `from === to === pos`, so the query was always + * empty and every completion matched (see FuzzyMatcher empty pattern). + */ +function getCompletionFilterRange( + text: string, + pos: number, + location: { isAtPropertyKey: boolean }, + valueNode: JsonNode | undefined +): { from: number; to: number } { + const tree = parseTree(text); + const isStringValueContext = + !location.isAtPropertyKey && valueNode?.type === 'string'; + + if (isStringValueContext && valueNode) { + return { + from: valueNode.offset + 1, + to: valueNode.offset + Math.max(valueNode.length - 1, 1), + }; + } + + if (location.isAtPropertyKey && tree && pos > 0) { + const keyNode = findNodeAtOffset(tree, pos - 1, true); + if (keyNode?.type === 'string' && pos >= keyNode.offset + 1) { + return { from: keyNode.offset + 1, to: pos }; + } + } + + return { from: pos, to: pos }; +} + +function dedupeCompletions(items: Completion[]): Completion[] { + const seen = new Set(); + const out: Completion[] = []; + for (const item of items) { + const key = `${item.label}|${item.type ?? ''}|${item.detail ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + return out; +} + +function _getWordBounds( + text: string, + offset: number +): { from: number; to: number } { + let from = offset; + let to = offset; + while (from > 0 && /[\w:-]/.test(text[from - 1] ?? '')) from -= 1; + while (to < text.length && /[\w:-]/.test(text[to] ?? '')) to += 1; + return { from, to }; +} + +function toJsonLiteral(value: unknown): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +/** Escape `$`, `}`, `\` for CodeMirror snippet templates (see Monaco `escapeSnippetValue`). */ +function escapeCodeMirrorSnippetText(s: string): string { + return s.replace(/[\\$}]/g, '\\$&'); +} + +/** + * When the user already typed the opening `"` of a property key, inserts must not + * include another leading `"` or acceptance produces `""json_schema": …`. + */ +function isInsideQuotedPropertyKey( + text: string, + pos: number, + isAtPropertyKey: boolean +): boolean { + if (!isAtPropertyKey || pos <= 0) return false; + const tree = parseTree(text); + if (!tree) return false; + const node = findNodeAtOffset(tree, pos - 1, true); + return node?.type === 'string' && pos >= node.offset + 1; +} + +/** + * - Eat a typed closing `"` after a partial property key (filter range ends at cursor). + * - Optionally insert `,` before the next sibling when the inserted value is single-line + * (skip multiline object/array snippets so we don't break cursor/snippet fields). + */ +function wrapPropertyCompletionApply( + completion: Completion, + options: { insideQuotedKey: boolean; autoCommaAfter: boolean } +): Completion { + if (typeof completion.apply !== 'function') { + return completion; + } + const innerApply = completion.apply; + return { + ...completion, + apply: (view, comp, from, to) => { + let end = to; + if (options.insideQuotedKey && view.state.sliceDoc(to, to + 1) === '"') { + end = to + 1; + } + const docLenBefore = view.state.doc.length; + const replacedLen = end - from; + innerApply(view, comp, from, end); + + if (!options.autoCommaAfter) return; + + // Snippet apply can leave main selection at the replace start (`from`) instead + // of after the inserted text — inserting `,` at `main.head` then yields `",enabled`. + const docLenAfter = view.state.doc.length; + const insertLen = docLenAfter - docLenBefore + replacedLen; + const valueEnd = from + insertLen; + + let scan = valueEnd; + const doc = view.state.doc; + while (scan < doc.length) { + const ch = doc.sliceString(scan, scan + 1); + if (!/\s/.test(ch)) break; + scan += 1; + } + const next = scan < doc.length ? doc.sliceString(scan, scan + 1) : ''; + if (next && next !== '}' && next !== ']' && next !== ',') { + view.dispatch({ + changes: { from: valueEnd, to: valueEnd, insert: ',' }, + selection: { anchor: valueEnd + 1 }, + }); + } + }, + }; +} + +function getPropertySuggestions( + text: string, + context: JsonEditorCodeMirrorContext, + path: Array, + offset: number, + isAtPropertyKey: boolean +): Completion[] { + const tree = parseJsonTree(text); + const activeEvaluator = resolveActiveEvaluator(context, tree, path); + const objectPath = path.slice(0, -1); + const schemaCursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + objectPath + ); + if (!schemaCursor.schema) return []; + + const objectNode = tree ? findNodeAtLocation(tree, objectPath) : undefined; + const existingKeys = new Set(); + if (objectNode?.type === 'object' && objectNode.children) { + for (const child of objectNode.children) { + const keyNode = child.children?.[0]; + if (typeof keyNode?.value === 'string') existingKeys.add(keyNode.value); + } + } else { + const nearText = text.slice( + Math.max(0, offset - 800), + Math.min(text.length, offset + 800) + ); + for (const match of nearText.matchAll(/"([^"\\]+)"\s*:/g)) { + const key = match[1]; + if (key) existingKeys.add(key); + } + } + + const insideQuotedKey = isInsideQuotedPropertyKey( + text, + offset, + isAtPropertyKey + ); + + const suggestions: Completion[] = []; + const properties = getSchemaProperties(schemaCursor.schema); + for (const [propertyName, rawSchema] of Object.entries(properties)) { + if (existingKeys.has(propertyName)) continue; + const normalized = normalizeSchema(rawSchema, schemaCursor.rootSchema); + const type = getSchemaType(normalized) ?? 'string'; + const valueInsert = getJsonInsertTextForSchemaPropertyValue( + rawSchema, + schemaCursor.rootSchema + ); + const escapedName = escapeCodeMirrorSnippetText(propertyName); + const snippetBody = insideQuotedKey + ? `${escapedName}": ${valueInsert}` + : `"${escapedName}": ${valueInsert}`; + const base = snippetCompletion(snippetBody, { + label: propertyName, + type: 'property', + detail: type, + }); + const autoCommaAfter = !valueInsert.includes('\n'); + suggestions.push({ + ...wrapPropertyCompletionApply(base, { + insideQuotedKey, + autoCommaAfter, + }), + info: getSchemaDescription(normalized) ?? undefined, + } as Completion); + } + return suggestions; +} + +function getValueSuggestions( + text: string, + context: JsonEditorCodeMirrorContext, + path: Array, + isStringValueContext: boolean +): Completion[] { + const tree = parseJsonTree(text); + if (isEvaluatorNameLocation(path) && context.evaluators?.length) { + return context.evaluators.map((item) => ({ + label: item.id, + type: 'constant', + detail: item.description ?? undefined, + info: item.description ?? undefined, + apply: ( + view: EditorView, + completion: Completion, + from: number, + to: number + ) => { + const insert = isStringValueContext ? item.id : JSON.stringify(item.id); + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); + closeCompletion(view); + }, + })); + } + + if (isSelectorPathLocation(path)) { + const { stepNames, stepTypes } = getScopeFilters(tree); + const stepPathSuggestions = context.steps + ?.filter((step) => + stepTypes.length > 0 ? step.type && stepTypes.includes(step.type) : true + ) + .filter((step) => + stepNames.length > 0 ? step.name && stepNames.includes(step.name) : true + ) + .map((step) => ({ + label: step.name ?? '', + detail: step.type ?? '', + rank: 60, + })) + .filter((item) => item.label.length > 0); + + const base = ROOT_SELECTOR_PATHS.map((label) => ({ + label, + detail: 'selector root', + rank: 100, + })); + return dedupeCompletions( + [...base, ...(stepPathSuggestions ?? [])] + .sort((a, b) => b.rank - a.rank) + .map((item) => ({ + label: item.label, + type: 'variable' as const, + detail: item.detail, + info: item.detail, + apply: ( + view: EditorView, + completion: Completion, + from: number, + to: number + ) => { + const insert = isStringValueContext + ? item.label + : JSON.stringify(item.label); + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); + }, + })) + ); + } + + const activeEvaluator = resolveActiveEvaluator(context, tree, path); + const cursor = resolveSchemaAtJsonPath(context, activeEvaluator, path); + const enumValues = getEnumValues(cursor.schema); + if (enumValues.length === 0) return []; + return enumValues.map((value) => ({ + label: String(value), + type: 'enum', + info: typeof value === 'string' ? `Enum value: ${value}` : 'Enum value', + apply: ( + view: EditorView, + completion: Completion, + from: number, + to: number + ) => { + const insert = + isStringValueContext && typeof value === 'string' + ? value + : toJsonLiteral(value); + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); + }, + })); +} + +function findConditionAtOffset( + node: JsonNode, + offset: number +): { + node: JsonNode; + isLeaf: boolean; + isArray: boolean; + arrayKey: string | null; +} | null { + if (offset < node.offset || offset > node.offset + node.length) return null; + if (node.type !== 'object' || !node.children) return null; + + for (const prop of node.children) { + const key = prop.children?.[0]?.value; + const value = prop.children?.[1]; + if (!value) continue; + if ( + (key === 'and' || key === 'or') && + value.type === 'array' && + value.children + ) { + for (const item of value.children) { + const inner = findConditionAtOffset(item, offset); + if (inner) return inner; + } + if (offset >= value.offset && offset <= value.offset + value.length) { + return { node, isLeaf: false, isArray: true, arrayKey: key as string }; + } + } else if (key === 'not' && value.type === 'object') { + const inner = findConditionAtOffset(value, offset); + if (inner) return inner; + } + } + + const hasSelector = !!findNodeAtLocation(node, ['selector']); + const hasEvaluator = !!findNodeAtLocation(node, ['evaluator']); + const hasAnd = !!findNodeAtLocation(node, ['and']); + const hasOr = !!findNodeAtLocation(node, ['or']); + const hasNot = !!findNodeAtLocation(node, ['not']); + const isLeaf = (hasSelector || hasEvaluator) && !hasAnd && !hasOr; + return { + node, + isLeaf, + isArray: false, + arrayKey: hasAnd ? 'and' : hasOr ? 'or' : hasNot ? 'not' : null, + }; +} + +type RefactorAction = { + label: string; + apply: (view: EditorView) => void; +}; + +const refactorCompletionArmed = new WeakMap(); + +function buildConditionRefactorActions( + text: string, + offset: number +): RefactorAction[] { + const tree = parseTree(text); + if (!tree) return []; + const conditionNode = findNodeAtLocation(tree, ['condition']); + if (!conditionNode) return []; + const condCtx = findConditionAtOffset(conditionNode, offset); + if (!condCtx) return []; + + const { node, isLeaf, isArray, arrayKey } = condCtx; + const nodeText = text.substring(node.offset, node.offset + node.length); + let parsedNode: unknown; + try { + parsedNode = JSON.parse(nodeText); + } catch { + return []; + } + + const applyNodeTransform = ( + transform: (parsed: unknown) => unknown + ): string | null => { + const transformed = transform(parsedNode); + if (transformed === undefined) return null; + const rawDoc = + text.substring(0, node.offset) + + JSON.stringify(transformed) + + text.substring(node.offset + node.length); + try { + return JSON.stringify(JSON.parse(rawDoc), null, 2); + } catch { + return ( + text.substring(0, node.offset) + + JSON.stringify(transformed, null, 2) + + text.substring(node.offset + node.length) + ); + } + }; + + const actions: RefactorAction[] = []; + if (isLeaf) { + actions.push( + { + label: 'Wrap in AND (add another condition)', + apply: (view) => { + const next = applyNodeTransform((p) => ({ + and: [ + p as Record, + { selector: { path: '*' }, evaluator: { name: '', config: {} } }, + ], + })); + if (!next) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + }); + }, + }, + { + label: 'Wrap in OR (add another condition)', + apply: (view) => { + const next = applyNodeTransform((p) => ({ + or: [ + p as Record, + { selector: { path: '*' }, evaluator: { name: '', config: {} } }, + ], + })); + if (!next) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + }); + }, + }, + { + label: 'Wrap in NOT', + apply: (view) => { + const next = applyNodeTransform((p) => ({ not: p })); + if (!next) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + }); + }, + } + ); + } + + if (isArray && (arrayKey === 'and' || arrayKey === 'or')) { + const otherKey = arrayKey === 'and' ? 'or' : 'and'; + actions.push( + { + label: `Add condition to ${arrayKey.toUpperCase()}`, + apply: (view) => { + const next = applyNodeTransform((p) => { + const obj = p as Record; + const arr = obj[arrayKey]; + if (!Array.isArray(arr)) return undefined; + return { + ...obj, + [arrayKey]: [ + ...arr, + { + selector: { path: '*' }, + evaluator: { name: '', config: {} }, + }, + ], + }; + }); + if (!next) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + }); + }, + }, + { + label: `Convert ${arrayKey.toUpperCase()} to ${otherKey.toUpperCase()}`, + apply: (view) => { + const next = applyNodeTransform((p) => { + const obj = p as Record; + const arr = obj[arrayKey]; + delete obj[arrayKey]; + return { ...obj, [otherKey]: arr }; + }); + if (!next) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + }); + }, + } + ); + } + + if (arrayKey === 'not') { + actions.push({ + label: 'Remove NOT (unwrap)', + apply: (view) => { + const next = applyNodeTransform( + (p) => (p as Record).not + ); + if (!next) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + }); + }, + }); + } + + return actions; +} + +function _toRefactorCompletions(actions: RefactorAction[]): Completion[] { + return actions.map((action) => ({ + label: action.label, + type: 'method', + apply: (view) => { + action.apply(view); + closeCompletion(view); + }, + })); +} + +type RefactorContext = { + from: number; + to: number; + actions: RefactorAction[]; +}; + +function getRefactorContext( + text: string, + offset: number, + mode: JsonEditorCodeMirrorContext['mode'] +): RefactorContext | null { + if (mode !== 'control') return null; + const tree = parseTree(text); + if (!tree) return null; + const conditionNode = findNodeAtLocation(tree, ['condition']); + if (!conditionNode) return null; + const condCtx = findConditionAtOffset(conditionNode, offset); + if (!condCtx) return null; + const actions = buildConditionRefactorActions(text, offset); + if (actions.length === 0) return null; + return { + from: condCtx.node.offset, + to: condCtx.node.offset + condCtx.node.length, + actions, + }; +} + +class LightbulbGutterMarker extends GutterMarker { + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.textContent = '💡'; + span.title = 'Show refactor actions'; + span.style.cursor = 'pointer'; + span.style.opacity = '0.9'; + return span; + } +} + +class HintWidget extends WidgetType { + constructor(private readonly hint: string) { + super(); + } + + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.style.color = 'var(--mantine-color-gray-5)'; + span.style.fontStyle = 'italic'; + span.style.pointerEvents = 'none'; + span.textContent = this.hint; + return span; + } +} + +function getHintForPath( + text: string, + path: Array, + context: JsonEditorCodeMirrorContext +): string | null { + // Avoid showing hint widgets for fields that already have a good dropdown UX. + if (isEvaluatorNameLocation(path)) { + return null; + } + + // Avoid showing the enum value hint widget for action decision because it + // duplicates/competes with the dropdown UI (user-reported). + // This hint widget is only shown for empty string values (see _createHintsExtension). + const last = path[path.length - 1]; + if ( + context.mode === 'control' && + last === 'decision' && + path.includes('action') + ) { + return null; + } + + const tree = parseJsonTree(text); + if (isEvaluatorNameLocation(path) && context.evaluators?.length) { + const display = context.evaluators + .map((item) => item.id) + .slice(0, MAX_HINT_VALUES); + return ` ${display.join(' | ')}${context.evaluators.length > MAX_HINT_VALUES ? ' | ...' : ''}`; + } + + if (isSelectorPathLocation(path)) { + return ' * | input | output | context | ...'; + } + + const activeEvaluator = resolveActiveEvaluator(context, tree, path); + const cursor = resolveSchemaAtJsonPath(context, activeEvaluator, path); + const enumValues = getEnumValues(cursor.schema); + if (enumValues.length > 0 && enumValues.length <= MAX_HINT_VALUES) { + return ` ${enumValues.map(String).join(' | ')}`; + } + return null; +} + +/** + * `activateOnTyping` often does not reopen completions after Backspace. + * Also reopen when the user edits inside a JSON string that has value + * suggestions (enums, evaluator name, selector path), including partial text + * like `"s"` after deleting `"sdk"`. + * + * Only runs for direct typing/paste/delete — not programmatic doc updates + * (for example default `config` injection after an evaluator rename). + */ +function _createAutocompleteOpenWhenValueSuggestionsAfterEditExtension( + context: JsonEditorCodeMirrorContext +): Extension { + return ViewPlugin.fromClass( + class { + private openQueued = false; + + update(update: ViewUpdate) { + if (!update.docChanged) return; + if ( + update.transactions.some((tr) => tr.isUserEvent('input.complete')) + ) { + return; + } + // Ignore programmatic doc changes (e.g. evaluator `config` auto-fill); those + // must not queue another completion — the dropdown would pop right back. + if ( + !update.transactions.some( + (tr) => + tr.isUserEvent('input.type') || + tr.isUserEvent('input.paste') || + tr.isUserEvent('input.drop') || + tr.isUserEvent('delete') + ) + ) { + return; + } + + const view = update.view; + const pos = view.state.selection.main.head; + const text = view.state.doc.toString(); + + const location = getLocation(text, pos); + if (!location.path.length || location.isAtPropertyKey) return; + + const tree = parseTree(text); + if (!tree) return; + + const valueNode = findNodeAtLocation(tree, location.path); + if (!valueNode || valueNode.type !== 'string') return; + if (typeof valueNode.value !== 'string') return; + + // Ensure the cursor is inside the editable portion of the string + // (between the quotes) before opening. + const innerFrom = valueNode.offset + 1; + const innerTo = valueNode.offset + Math.max(valueNode.length - 1, 1); + if (pos < innerFrom || pos > innerTo) return; + + const options = getValueSuggestions( + text, + context, + location.path, + true /* isStringValueContext */ + ); + if (!options || options.length === 0) return; + + // CodeMirror forbids dispatching while an update is in progress. + // Queue the completion open to the next tick. + if (this.openQueued) return; + this.openQueued = true; + window.setTimeout(() => { + try { + startCompletion(view); + } finally { + this.openQueued = false; + } + }, 0); + } + } + ); +} + +function _createHintsExtension( + context: JsonEditorCodeMirrorContext +): Extension { + return ViewPlugin.fromClass( + class { + decorations = Decoration.none; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: { + docChanged: boolean; + viewportChanged: boolean; + view: EditorView; + }) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView) { + const text = view.state.doc.toString(); + const tree = parseJsonTree(text); + if (!tree) return Decoration.none; + + const emptyStringPattern = /:\s*""/g; + const ranges: Range[] = []; + let match: RegExpExecArray | null; + while ((match = emptyStringPattern.exec(text)) !== null) { + const quoteOffset = match.index + match[0].length - 1; + const location = getLocation(text, quoteOffset); + if (location.isAtPropertyKey) continue; + const hint = getHintForPath(text, location.path, context); + if (!hint) continue; + ranges.push( + Decoration.widget({ side: 1, widget: new HintWidget(hint) }).range( + quoteOffset + 1 + ) + ); + } + return Decoration.set(ranges, true); + } + }, + { decorations: (value) => value.decorations } + ); +} + +function _createHoverExtension( + context: JsonEditorCodeMirrorContext +): Extension { + return hoverTooltip((view, pos) => { + const text = view.state.doc.toString(); + const tree = parseJsonTree(text); + const location = getLocation(text, pos); + if (!location.path.length) return null; + + const activeEvaluator = resolveActiveEvaluator( + context, + tree, + location.path + ); + const path = location.isAtPropertyKey + ? location.path.slice(0, -1) + : location.path; + const cursor = resolveSchemaAtJsonPath(context, activeEvaluator, path); + + let title: string | null = null; + let description: string | null = null; + let enumValues: unknown[] = []; + + if (location.isAtPropertyKey) { + const propName = location.path[location.path.length - 1]; + if (typeof propName !== 'string' || !cursor.schema) return null; + const propSchema = getSchemaAtProperty( + cursor.schema, + propName, + cursor.rootSchema + ); + title = getSchemaTitle(propSchema); + description = getSchemaDescription(propSchema); + enumValues = getEnumValues(propSchema); + } else { + title = getSchemaTitle(cursor.schema); + description = getSchemaDescription(cursor.schema); + enumValues = getEnumValues(cursor.schema); + } + + if (!title && !description && enumValues.length === 0) return null; + + const dom = document.createElement('div'); + dom.style.maxWidth = '420px'; + dom.style.whiteSpace = 'normal'; + if (title) { + const heading = document.createElement('div'); + heading.style.fontWeight = '600'; + heading.textContent = title; + dom.appendChild(heading); + } + if (description) { + const body = document.createElement('div'); + body.style.marginTop = title ? '4px' : '0'; + body.textContent = description; + dom.appendChild(body); + } + if (enumValues.length > 0) { + const enumLine = document.createElement('div'); + enumLine.style.marginTop = '6px'; + enumLine.textContent = `Values: ${enumValues.map(String).join(' | ')}`; + dom.appendChild(enumLine); + } + return { pos, end: pos, create: () => ({ dom }) }; + }); +} + +const completionNavigationKeymap = Prec.highest( + keymap.of([ + { key: 'ArrowDown', run: moveCompletionSelection(true) }, + { key: 'ArrowUp', run: moveCompletionSelection(false) }, + { key: 'Enter', run: acceptCompletion }, + ]) +); + +export function buildCodeMirrorJsonExtensions( + context: JsonEditorCodeMirrorContext, + options?: { + enableHoverExtension?: boolean; + enableHintsExtension?: boolean; + } +): Extension[] { + const enableHoverExtension = options?.enableHoverExtension ?? true; + // Hints are intentionally off by default — dropdown completions cover the UX. + const enableHintsExtension = options?.enableHintsExtension ?? false; + + return [ + autocompletion({ + activateOnTyping: true, + override: [ + (completionContext) => { + const text = completionContext.state.doc.toString(); + const location = getLocation(text, completionContext.pos); + const tree = parseTree(text); + const valueNode = tree + ? findNodeAtLocation(tree, location.path) + : undefined; + const isStringValueContext = + !location.isAtPropertyKey && valueNode?.type === 'string'; + + const range = getCompletionFilterRange( + text, + completionContext.pos, + location, + valueNode + ); + + const view = completionContext.view; + if (view && refactorCompletionArmed.get(view)) { + refactorCompletionArmed.set(view, false); + const refactorContext = getRefactorContext( + text, + view.state.selection.main.head, + context.mode + ); + if (refactorContext) { + // Keep the completion UI anchored at the caret line. + // The actual refactor actions rewrite the whole document, + // so `from/to` here only controls dropdown placement. + const anchor = completionContext.pos; + return { + from: anchor, + to: anchor, + filter: false, + options: _toRefactorCompletions(refactorContext.actions), + }; + } + } + + const options = dedupeCompletions( + location.isAtPropertyKey + ? getPropertySuggestions( + text, + context, + location.path, + completionContext.pos, + location.isAtPropertyKey + ) + : getValueSuggestions( + text, + context, + location.path, + isStringValueContext + ) + ); + + if (options.length === 0) { + return null; + } + + return { + from: range.from, + to: range.to, + filter: true, + options, + }; + }, + ], + }), + completionNavigationKeymap, + keymap.of(completionKeymap), + // Backspace/delete often does not re-trigger `activateOnTyping`; reopen + // completions whenever we are editing a string that has value suggestions. + _createAutocompleteOpenWhenValueSuggestionsAfterEditExtension(context), + buildCodeMirrorRefactorLightbulbExtension(context), + ...(enableHoverExtension ? [_createHoverExtension(context)] : []), + ...(enableHintsExtension ? [_createHintsExtension(context)] : []), + ]; +} + +export function buildCodeMirrorStandaloneDebugExtensions(): Extension[] { + const rootKeys = ['execution', 'action', 'scope'] as const; + return [ + autocompletion({ + activateOnTyping: true, + override: [ + (completionContext) => { + const text = completionContext.state.doc.toString(); + const location = getLocation(text, completionContext.pos); + const tree = parseTree(text); + const valueNode = tree + ? findNodeAtLocation(tree, location.path) + : undefined; + const isStringValueContext = + !location.isAtPropertyKey && valueNode?.type === 'string'; + const range = getCompletionFilterRange( + text, + completionContext.pos, + location, + valueNode + ); + + if (location.isAtPropertyKey) { + return { + from: range.from, + to: range.to, + options: rootKeys.map((key) => ({ + label: key, + type: 'property', + apply: ( + view: EditorView, + completion: Completion, + from: number, + to: number + ) => { + const insert = `"${key}"`; + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); + }, + })), + }; + } + + const path = location.path; + let values: string[] = []; + if (path[path.length - 1] === 'execution') { + values = ['server', 'sdk']; + } else if ( + path.length >= 2 && + path[path.length - 2] === 'action' && + path[path.length - 1] === 'decision' + ) { + values = ['allow', 'deny']; + } else if ( + path.length >= 3 && + path[path.length - 3] === 'scope' && + path[path.length - 2] === 'stages' && + typeof path[path.length - 1] === 'number' + ) { + values = ['pre', 'post']; + } + + if (values.length === 0) return null; + return { + from: range.from, + to: range.to, + filter: true, + options: values.map((value) => ({ + label: value, + type: 'enum', + info: `Enum value: ${value}`, + apply: ( + view: EditorView, + completion: Completion, + from: number, + to: number + ) => { + const insert = isStringValueContext + ? value + : JSON.stringify(value); + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); + }, + })), + }; + }, + ], + }), + keymap.of(completionKeymap), + // completionNavigationKeymap, + ]; +} + +export function triggerRefactorActionsDropdown( + view: EditorView, + mode: JsonEditorCodeMirrorContext['mode'] +): boolean { + const text = view.state.doc.toString(); + const offset = view.state.selection.main.head; + const refactorContext = getRefactorContext(text, offset, mode); + if (!refactorContext) return false; + refactorCompletionArmed.set(view, true); + startCompletion(view); + return true; +} + +export function buildCodeMirrorRefactorLightbulbExtension( + context: JsonEditorCodeMirrorContext +): Extension { + const marker = new LightbulbGutterMarker(); + return gutter({ + class: 'cm-refactor-lightbulb-gutter', + initialSpacer: () => marker, + markers(view) { + const text = view.state.doc.toString(); + const offset = view.state.selection.main.head; + const refactorContext = getRefactorContext(text, offset, context.mode); + const builder = new RangeSetBuilder(); + if (refactorContext) { + const line = view.state.doc.lineAt(offset); + builder.add(line.from, line.from, marker); + } + return builder.finish(); + }, + domEventHandlers: { + mousedown(view, _line) { + const text = view.state.doc.toString(); + const offset = view.state.selection.main.head; + const refactorContext = getRefactorContext(text, offset, context.mode); + if (!refactorContext) return false; + // Don't move the caret (keep bulb aligned with the user's caret line). + window.setTimeout(() => { + triggerRefactorActionsDropdown(view, context.mode); + }, 0); + return true; + }, + }, + }); +} + +export function getCodeMirrorCompletionItems( + text: string, + position: number, + context: JsonEditorCodeMirrorContext +): Array<{ label: string; detail?: string }> { + const location = getLocation(text, position); + const tree = parseTree(text); + const valueNode = tree ? findNodeAtLocation(tree, location.path) : undefined; + const isStringValueContext = + !location.isAtPropertyKey && valueNode?.type === 'string'; + const options = location.isAtPropertyKey + ? getPropertySuggestions( + text, + context, + location.path, + position, + location.isAtPropertyKey + ) + : getValueSuggestions(text, context, location.path, isStringValueContext); + + return dedupeCompletions(options).map((item) => ({ + label: item.label, + detail: typeof item.detail === 'string' ? item.detail : undefined, + })); +} + +export function shouldTriggerEvaluatorNameCompletion( + text: string, + offset: number +): boolean { + const location = getLocation(text, offset); + if (!isEvaluatorNameLocation(location.path)) { + return false; + } + + const tree = parseTree(text); + if (!tree) return true; + const node = findNodeAtLocation(tree, location.path); + if (!node) return true; + + if (node.type === 'string' && typeof node.value === 'string') { + return node.value.trim().length === 0; + } + + return false; +} diff --git a/ui/src/components/json-editor-codemirror/language/format.ts b/ui/src/components/json-editor-codemirror/language/format.ts new file mode 100644 index 00000000..6e53fb63 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/format.ts @@ -0,0 +1,90 @@ +import { + findNodeAtLocation, + getLocation, + type ParseError, + parseTree, +} from 'jsonc-parser'; + +import { removeTrailingCommasOutsideStrings } from '@/components/json-editor-shared/fix-json-commas'; + +/** + * Map a caret offset from JSON before a full-doc pretty-print to the matching + * offset after, using the JSON value at `getLocation(textBefore, caretBefore).path`. + */ +export function caretAfterPrettyJsonReplace( + textBefore: string, + caretBefore: number, + textAfter: string +): number | null { + const treeBefore = parseTree(textBefore); + const treeAfter = parseTree(textAfter); + if (!treeBefore || !treeAfter) { + return null; + } + + const loc = getLocation(textBefore, caretBefore); + if (loc.path.length === 0) { + return null; + } + + const nodeBefore = findNodeAtLocation(treeBefore, loc.path); + const nodeAfter = findNodeAtLocation(treeAfter, loc.path); + if (!nodeBefore || !nodeAfter) { + return null; + } + + if (nodeBefore.type === 'string' && nodeAfter.type === 'string') { + const innerStartBefore = nodeBefore.offset + 1; + const innerStartAfter = nodeAfter.offset + 1; + const innerLenBefore = Math.max(0, nodeBefore.length - 2); + const innerLenAfter = Math.max(0, nodeAfter.length - 2); + const rel = Math.min( + Math.max(caretBefore - innerStartBefore, 0), + innerLenBefore + ); + const relAfter = Math.min(rel, innerLenAfter); + return innerStartAfter + relAfter; + } + + const startB = nodeBefore.offset; + const endB = nodeBefore.offset + nodeBefore.length; + const clamped = Math.min(Math.max(caretBefore, startB), endB); + const ratio = + nodeBefore.length > 0 ? (clamped - startB) / nodeBefore.length : 0; + const offsetInAfter = Math.round(ratio * nodeAfter.length); + return Math.min( + Math.max(nodeAfter.offset, nodeAfter.offset + offsetInAfter), + nodeAfter.offset + nodeAfter.length + ); +} + +export function tryFormat(text: string): string | null { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return null; + } +} + +export function fixJsonCommas(text: string): string { + let fixed = removeTrailingCommasOutsideStrings(text); + const errors: ParseError[] = []; + parseTree(fixed, errors); + const commaErrors = errors + .filter((error) => error.error === 6) + .sort((a, b) => b.offset - a.offset); + for (const error of commaErrors) { + let insertAt = error.offset; + while (insertAt > 0 && /\s/.test(fixed[insertAt - 1] ?? '')) { + insertAt -= 1; + } + fixed = fixed.slice(0, insertAt) + ',' + fixed.slice(insertAt); + } + return fixed; +} + +export function normalizeOnBlur(text: string): string | null { + const fixed = fixJsonCommas(text); + if (fixed === text) return null; + return tryFormat(fixed) ? fixed : null; +} diff --git a/ui/src/components/json-editor-codemirror/language/index.ts b/ui/src/components/json-editor-codemirror/language/index.ts new file mode 100644 index 00000000..d52749df --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/index.ts @@ -0,0 +1,22 @@ +export { + applyTextEdit, + computeAutoEdit, + extractEvaluatorNames, +} from './auto-edits'; +export { + buildCodeMirrorInlineServerValidationErrorsExtension, + buildCodeMirrorJsonExtensions, + buildCodeMirrorRefactorLightbulbExtension, + buildCodeMirrorStandaloneDebugExtensions, + canRenderInlineServerValidationError, + getCodeMirrorCompletionItems, + setInlineServerValidationErrorsEffect, + shouldTriggerEvaluatorNameCompletion, + triggerRefactorActionsDropdown, +} from './extensions'; +export { + caretAfterPrettyJsonReplace, + fixJsonCommas, + normalizeOnBlur, + tryFormat, +} from './format'; diff --git a/ui/src/components/json-editor-codemirror/language/inline-server-validation.ts b/ui/src/components/json-editor-codemirror/language/inline-server-validation.ts new file mode 100644 index 00000000..077f52d2 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/inline-server-validation.ts @@ -0,0 +1,243 @@ +import { + type Extension, + type Range, + StateEffect, + StateField, +} from '@codemirror/state'; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from '@codemirror/view'; +import { + findNodeAtLocation, + type Node as JsonNode, + parseTree, +} from 'jsonc-parser'; + +import type { ValidationErrorItem } from '@/core/api/types'; + +type InlineServerValidationPayload = { + errors: ValidationErrorItem[]; +}; + +export const setInlineServerValidationErrorsEffect = + StateEffect.define(); + +const inlineServerValidationField = + StateField.define({ + create: () => ({ errors: [] }), + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setInlineServerValidationErrorsEffect)) { + return effect.value; + } + } + return value; + }, + }); + +const INLINE_VALIDATION_ERROR_THEME = EditorView.theme({ + '& .cm-inline-validation-error-key': { + backgroundColor: 'rgba(255, 0, 0, 0.18)', + borderBottom: '1px solid rgba(255, 0, 0, 0.55)', + }, +}); + +class InlineErrorWidget extends WidgetType { + constructor(private readonly message: string) { + super(); + } + + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.textContent = this.message; + span.style.color = 'var(--mantine-color-red-6)'; + span.style.fontSize = '11px'; + span.style.marginLeft = '8px'; + span.style.padding = '2px 6px'; + span.style.borderRadius = '999px'; + span.style.background = 'rgba(255, 0, 0, 0.12)'; + span.style.whiteSpace = 'nowrap'; + span.style.pointerEvents = 'none'; + return span; + } +} + +type JsonPath = Array; + +function apiFieldToJsonPath(apiField: string): JsonPath | null { + let field = apiField.trim(); + if (!field) return null; + + // Backend uses e.g. "data.action.decision". + const dataPrefix = 'data.'; + if (field.startsWith(dataPrefix)) { + field = field.slice(dataPrefix.length); + } + + // Convert simple "foo[0]" patterns into path segments. + const out: JsonPath = []; + for (const segment of field.split('.')) { + if (!segment) continue; + const m = segment.match(/^([^\[]+)\[(\d+)\]$/); + if (m) { + out.push(m[1]); + out.push(Number(m[2])); + continue; + } + out.push(segment); + } + return out; +} + +function findKeyAndValueRangesForJsonPath( + tree: JsonNode | undefined, + path: JsonPath +): { + keyRange: { from: number; to: number } | null; + valueRange: { from: number; to: number } | null; +} { + if (!tree || path.length === 0) { + return { keyRange: null, valueRange: null }; + } + + const valueNode = findNodeAtLocation(tree, path); + const valueRange = valueNode + ? { from: valueNode.offset, to: valueNode.offset + valueNode.length } + : null; + + const keySegment = path[path.length - 1]; + let keyRange: { from: number; to: number } | null = null; + + // If the last segment is a string, try to locate the property key token. + if (typeof keySegment === 'string') { + const parentPath = path.slice(0, -1); + const parentNode = + parentPath.length > 0 ? findNodeAtLocation(tree, parentPath) : tree; + + if (parentNode?.type === 'object' && parentNode.children) { + for (const prop of parentNode.children) { + const propKey = prop.children?.[0]; + if ( + typeof propKey?.value === 'string' && + propKey.value === keySegment + ) { + keyRange = { + from: propKey.offset, + to: propKey.offset + propKey.length, + }; + break; + } + } + } + } + + return { keyRange, valueRange }; +} + +function computeInlineValidationDecorations( + view: EditorView, + payload: InlineServerValidationPayload +): DecorationSet { + const text = view.state.doc.toString(); + const tree = parseTree(text); + if (!tree) return Decoration.none; + + const ranges: Range[] = []; + for (const err of payload.errors) { + if (!err.field) continue; + const jsonPath = apiFieldToJsonPath(err.field); + if (!jsonPath) continue; + + const { keyRange, valueRange } = findKeyAndValueRangesForJsonPath( + tree, + jsonPath + ); + if (!keyRange && !valueRange) continue; + + const widget = new InlineErrorWidget(err.message); + + // Prefer highlighting the value so the user sees "what's wrong" + // (e.g. highlight `"execution": "sdk"` rather than `"execution"`). + // `markRange` can't be null here because we `continue` when both + // `valueRange` and `keyRange` are missing. + const markRange = (valueRange ?? keyRange)!; + const widgetAfter = valueRange?.to ?? markRange.to; + + ranges.push( + Decoration.mark({ class: 'cm-inline-validation-error-key' }).range( + markRange.from, + markRange.to + ) + ); + // Always place the widget after the *value* so it renders after + // `"execution": "sdk"` instead of between the key and value. + ranges.push(Decoration.widget({ side: 1, widget }).range(widgetAfter)); + } + + return Decoration.set(ranges, true); +} + +export function canRenderInlineServerValidationError( + text: string, + error: Pick +): boolean { + if (!error.field) return false; + + const tree = parseTree(text); + if (!tree) return false; + + const jsonPath = apiFieldToJsonPath(error.field); + if (!jsonPath) return false; + + const { keyRange, valueRange } = findKeyAndValueRangesForJsonPath( + tree, + jsonPath + ); + + return Boolean(keyRange || valueRange); +} + +export function buildCodeMirrorInlineServerValidationErrorsExtension(): Extension { + return [ + INLINE_VALIDATION_ERROR_THEME, + inlineServerValidationField, + ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none; + private lastSignature = ''; + + constructor(view: EditorView) { + const payload = view.state.field(inlineServerValidationField); + this.lastSignature = this.signature(payload); + this.decorations = computeInlineValidationDecorations(view, payload); + } + + update(update: ViewUpdate) { + const payload = update.state.field(inlineServerValidationField); + const sig = this.signature(payload); + if (!update.docChanged && sig === this.lastSignature) return; + this.lastSignature = sig; + this.decorations = computeInlineValidationDecorations( + update.view, + payload + ); + } + + private signature(payload: InlineServerValidationPayload): string { + if (!payload.errors.length) return ''; + return payload.errors + .map((e) => `${e.field ?? ''}|${e.code}|${e.message}`) + .join('\n'); + } + }, + { + decorations: (plugin) => plugin.decorations, + } + ), + ]; +} diff --git a/ui/src/components/json-editor-codemirror/language/schema.ts b/ui/src/components/json-editor-codemirror/language/schema.ts new file mode 100644 index 00000000..6627c739 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/schema.ts @@ -0,0 +1,306 @@ +import type { JsonSchema } from '@/core/page-components/agent-detail/modals/edit-control/types'; + +import { SCHEMA_COMPOSITION_KEYS } from './types'; + +export function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function asSchema(schema: unknown): JsonSchema | null { + return isObject(schema) ? schema : null; +} + +export function getSchemaTypes(schema: unknown): string[] { + if (!isObject(schema)) return []; + if (typeof schema.type === 'string') return [schema.type]; + if (!Array.isArray(schema.type)) return []; + return schema.type.filter( + (value): value is string => typeof value === 'string' + ); +} + +export function getSchemaType(schema: unknown): string | null { + return getSchemaTypes(schema).find((value) => value !== 'null') ?? null; +} + +export function getSchemaEnumValues(schema: unknown): unknown[] { + return isObject(schema) && Array.isArray(schema.enum) ? schema.enum : []; +} + +export function getSchemaDefault(schema: unknown): unknown { + return isObject(schema) && 'default' in schema ? schema.default : undefined; +} + +export function getSchemaDescription(schema: unknown): string | null { + return isObject(schema) && typeof schema.description === 'string' + ? schema.description + : null; +} + +export function getSchemaTitle(schema: unknown): string | null { + return isObject(schema) && typeof schema.title === 'string' + ? schema.title + : null; +} + +export function getSchemaProperties(schema: unknown): Record { + return isObject(schema) && isObject(schema.properties) + ? (schema.properties as Record) + : {}; +} + +export function getSchemaRequiredProperties(schema: unknown): string[] { + if (!isObject(schema) || !Array.isArray(schema.required)) return []; + return schema.required.filter( + (value): value is string => typeof value === 'string' + ); +} + +function unescapeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function resolveJsonPointer( + rootSchema: JsonSchema | null, + ref: string +): JsonSchema | null { + if (!rootSchema || !ref.startsWith('#/')) return null; + let current: unknown = rootSchema; + for (const segment of ref + .slice(2) + .split('/') + .map(unescapeJsonPointerSegment)) { + if (!isObject(current) || !(segment in current)) return null; + current = current[segment]; + } + return asSchema(current); +} + +function stripCompositionKeys(schema: JsonSchema): JsonSchema { + const stripped = { ...schema }; + for (const key of SCHEMA_COMPOSITION_KEYS) { + delete stripped[key]; + } + return stripped; +} + +function mergeSchemas( + schemas: JsonSchema[], + baseSchema?: JsonSchema | null +): JsonSchema { + const merged: JsonSchema = baseSchema ? stripCompositionKeys(baseSchema) : {}; + const properties: Record = {}; + const required = new Set(); + const enumValues: unknown[] = []; + const types = new Set(); + let items: unknown; + let additionalProperties: unknown; + + for (const schema of schemas) { + for (const type of getSchemaTypes(schema)) { + if (type !== 'null') types.add(type); + } + for (const value of getSchemaEnumValues(schema)) { + if (!enumValues.some((candidate) => candidate === value)) { + enumValues.push(value); + } + } + if (isObject(schema.properties)) + Object.assign(properties, schema.properties); + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + if (typeof key === 'string') required.add(key); + } + } + if (schema.items !== undefined) items = schema.items; + if (schema.additionalProperties !== undefined) { + additionalProperties = schema.additionalProperties; + } + } + + if (Object.keys(properties).length > 0) merged.properties = properties; + if (required.size > 0) merged.required = [...required]; + if (enumValues.length > 0) merged.enum = enumValues; + if (types.size === 1) merged.type = [...types][0]; + if (types.size > 1) merged.type = [...types]; + if (items !== undefined) merged.items = items; + if (additionalProperties !== undefined) + merged.additionalProperties = additionalProperties; + + return merged; +} + +export function normalizeSchema( + schema: unknown, + rootSchema: JsonSchema | null +): JsonSchema | null { + const asObj = asSchema(schema); + if (!asObj) return null; + + let normalized = asObj; + if (typeof asObj.$ref === 'string') { + const resolved = resolveJsonPointer(rootSchema, asObj.$ref); + if (resolved) normalized = { ...resolved, ...stripCompositionKeys(asObj) }; + } + + const composedSchemas: JsonSchema[] = []; + for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { + const value = normalized[key]; + if (!Array.isArray(value)) continue; + for (const child of value) { + const childSchema = normalizeSchema(child, rootSchema); + if (childSchema) composedSchemas.push(childSchema); + } + } + + return composedSchemas.length > 0 + ? mergeSchemas(composedSchemas, stripCompositionKeys(normalized)) + : normalized; +} + +export function getSchemaAtProperty( + schema: JsonSchema | null, + property: string, + rootSchema: JsonSchema | null +): JsonSchema | null { + if (!schema) return null; + const normalized = normalizeSchema(schema, rootSchema); + if (!normalized) return null; + + const properties = getSchemaProperties(normalized); + if (property in properties) { + return normalizeSchema(properties[property], rootSchema); + } + + if ( + normalized.additionalProperties && + isObject(normalized.additionalProperties) + ) { + return normalizeSchema(normalized.additionalProperties, rootSchema); + } + + return null; +} + +function isSchemaWithProperties( + schema: JsonSchema, + propertyNames: readonly string[] +): boolean { + const properties = getSchemaProperties(schema); + return propertyNames.every((name) => name in properties); +} + +function getSchemaExamples(schema: unknown): unknown[] { + return isObject(schema) && Array.isArray(schema.examples) + ? schema.examples + : []; +} + +function jsonStringifyForInsert(value: unknown): string { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return JSON.stringify(value); + } + return JSON.stringify(value, null, 2); +} + +/** + * JSON text inserted when completing a property key. Mirrors Monaco + * `buildSchemaValueSnippet` so control scaffolding (selector, evaluator, + * action, scope) matches the original editor. + */ +export function getJsonInsertTextForSchemaPropertyValue( + rawSchema: unknown, + rootSchema: JsonSchema | null +): string { + const normalized = normalizeSchema(rawSchema, rootSchema); + if (!normalized) { + return 'null'; + } + + const enumValues = getSchemaEnumValues(normalized); + if (enumValues.length > 0) { + return jsonStringifyForInsert(enumValues[0]); + } + + const examples = getSchemaExamples(normalized); + const defaultValue = getSchemaDefault(normalized); + const preferredValue = + defaultValue !== undefined ? defaultValue : examples[0]; + const schemaTitle = getSchemaTitle(normalized); + + if ( + schemaTitle === 'ControlSelector' || + isSchemaWithProperties(normalized, ['path']) + ) { + return '{\n "path": "*"\n}'; + } + + if ( + schemaTitle === 'EvaluatorSpec' || + isSchemaWithProperties(normalized, ['name', 'config']) + ) { + return '{\n "name": "",\n "config": {}\n}'; + } + + if ( + schemaTitle === 'ControlAction' || + isSchemaWithProperties(normalized, ['decision', 'steering_context']) + ) { + return '{\n "decision": "deny"\n}'; + } + + if ( + schemaTitle === 'ControlScope' || + isSchemaWithProperties(normalized, ['step_types', 'stages']) + ) { + return '{\n "step_types": ["llm"],\n "stages": ["post"]\n}'; + } + + if ( + schemaTitle === 'ConditionNode' || + isSchemaWithProperties(normalized, [ + 'selector', + 'evaluator', + 'and', + 'or', + 'not', + ]) + ) { + return '{}'; + } + + switch (getSchemaType(normalized)) { + case 'object': { + return '{}'; + } + case 'array': { + return '[]'; + } + case 'boolean': { + return String( + typeof preferredValue === 'boolean' ? preferredValue : true + ); + } + case 'integer': + case 'number': { + return String(typeof preferredValue === 'number' ? preferredValue : 0); + } + case 'string': { + if (typeof preferredValue === 'string' && preferredValue.length > 0) { + return JSON.stringify(preferredValue); + } + return '""'; + } + default: { + if (preferredValue !== undefined) { + return jsonStringifyForInsert(preferredValue); + } + return 'null'; + } + } +} diff --git a/ui/src/components/json-editor-codemirror/language/types.ts b/ui/src/components/json-editor-codemirror/language/types.ts new file mode 100644 index 00000000..35e9b73e --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/types.ts @@ -0,0 +1,40 @@ +import type { StepSchema } from '@/core/api/types'; +import type { + JsonEditorEvaluatorOption, + JsonEditorMode, + JsonSchema, +} from '@/core/page-components/agent-detail/modals/edit-control/types'; + +export type JsonPath = Array; + +export type JsonEditorCodeMirrorContext = { + mode: JsonEditorMode; + schema?: JsonSchema | null; + evaluators?: JsonEditorEvaluatorOption[]; + activeEvaluatorId?: string | null; + steps?: StepSchema[]; +}; + +export type JsonEditorTextEdit = { + offset: number; + length: number; + newText: string; +}; + +export type SchemaCursor = { + schema: JsonSchema | null; + rootSchema: JsonSchema | null; +}; + +export const ROOT_SELECTOR_PATHS = [ + '*', + 'input', + 'output', + 'context', + 'name', + 'type', +]; + +export const SCHEMA_COMPOSITION_KEYS = ['$ref', 'allOf', 'anyOf', 'oneOf']; + +export const MAX_HINT_VALUES = 6; diff --git a/ui/src/components/json-editor-monaco/index.ts b/ui/src/components/json-editor-monaco/index.ts new file mode 100644 index 00000000..03fa285a --- /dev/null +++ b/ui/src/components/json-editor-monaco/index.ts @@ -0,0 +1 @@ +export { JsonEditorMonaco } from './json-editor-monaco'; diff --git a/ui/src/components/json-editor-monaco/json-editor-monaco-language.ts b/ui/src/components/json-editor-monaco/json-editor-monaco-language.ts new file mode 100644 index 00000000..1b41008d --- /dev/null +++ b/ui/src/components/json-editor-monaco/json-editor-monaco-language.ts @@ -0,0 +1,1941 @@ +import { + findNodeAtLocation, + findNodeAtOffset, + getLocation, + type Node as JsonNode, + type ParseError, + parseTree, +} from 'jsonc-parser'; + +import { removeTrailingCommasOutsideStrings } from '@/components/json-editor-shared/fix-json-commas'; +import type { StepSchema } from '@/core/api/types'; +import type { + JsonEditorEvaluatorOption, + JsonEditorMode, + JsonSchema, +} from '@/core/page-components/agent-detail/modals/edit-control/types'; + +type MonacoModule = typeof import('monaco-editor'); +type JsonPath = Array; + +type JsonEditorAutocompleteContext = { + mode: JsonEditorMode; + modelUri: string; + schema?: JsonSchema | null; + evaluators?: JsonEditorEvaluatorOption[]; + activeEvaluatorId?: string | null; + steps?: StepSchema[]; +}; + +type SelectorPathSuggestion = { + label: string; + detail: string; + rank: number; +}; + +type SchemaCursor = { + schema: JsonSchema | null; + rootSchema: JsonSchema | null; +}; + +type SnippetState = { + nextTabStop: number; +}; + +const ROOT_SELECTOR_PATHS = ['*', 'input', 'output', 'context', 'name', 'type']; +const COMPLETION_TRIGGER_CHARACTERS = ['"', ':', '.', ',', '[']; +const SCHEMA_COMPOSITION_KEYS = ['$ref', 'allOf', 'anyOf', 'oneOf']; +const RESERVED_SCHEMA_KEYS = new Set([ + ...SCHEMA_COMPOSITION_KEYS, + '$defs', + 'additionalProperties', + 'default', + 'description', + 'enum', + 'examples', + 'items', + 'properties', + 'required', + 'title', + 'type', +]); + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function asSchema(schema: unknown): JsonSchema | null { + return isObject(schema) ? schema : null; +} + +function getStringArrayAtPath( + tree: JsonNode | undefined, + path: JsonPath +): string[] { + const node = tree ? findNodeAtLocation(tree, path) : undefined; + if (!node || node.type !== 'array' || !node.children) { + return []; + } + + return node.children + .map((child) => (typeof child.value === 'string' ? child.value : null)) + .filter((value): value is string => value !== null); +} + +function getScopeFilters(tree: JsonNode | undefined): { + stepTypes: string[]; + stepNames: string[]; +} { + return { + stepTypes: getStringArrayAtPath(tree, ['scope', 'step_types']), + stepNames: getStringArrayAtPath(tree, ['scope', 'step_names']), + }; +} + +function getJsonPathFieldIndex(path: JsonPath, fieldName: string): number { + for (let index = path.length - 1; index >= 0; index -= 1) { + if (path[index] === fieldName) { + return index; + } + } + return -1; +} + +function getRangeForNodeContent( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + node: JsonNode | undefined +) { + if (!node || node.type !== 'string') { + return null; + } + + const start = model.getPositionAt(node.offset + 1); + const end = model.getPositionAt(node.offset + Math.max(node.length - 1, 1)); + + return new monaco.Range( + start.lineNumber, + start.column, + end.lineNumber, + end.column + ); +} + +function getDefaultRange( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position +) { + const word = model.getWordUntilPosition(position); + + return new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ); +} + +function getReplaceRange( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position, + node: JsonNode | undefined +) { + return ( + getRangeForNodeContent(monaco, model, node) ?? + getDefaultRange(monaco, model, position) + ); +} + +function getPropertyKeyReplaceRange( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + node: JsonNode | undefined +) { + if (!node || node.type !== 'string') { + return null; + } + + const start = model.getPositionAt(node.offset + 1); + const end = model.getPositionAt(node.offset + node.length); + + return new monaco.Range( + start.lineNumber, + start.column, + end.lineNumber, + end.column + ); +} + +function unescapeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function resolveJsonPointer( + rootSchema: JsonSchema | null, + ref: string +): JsonSchema | null { + if (!rootSchema || !ref.startsWith('#/')) { + return null; + } + + let current: unknown = rootSchema; + for (const segment of ref + .slice(2) + .split('/') + .map(unescapeJsonPointerSegment)) { + if (!isObject(current) || !(segment in current)) { + return null; + } + current = current[segment]; + } + + return asSchema(current); +} + +function getSchemaTypes(schema: unknown): string[] { + if (!isObject(schema)) { + return []; + } + + if (typeof schema.type === 'string') { + return [schema.type]; + } + + if (!Array.isArray(schema.type)) { + return []; + } + + return schema.type.filter( + (value): value is string => typeof value === 'string' + ); +} + +function getSchemaType(schema: unknown): string | null { + return getSchemaTypes(schema).find((value) => value !== 'null') ?? null; +} + +function getSchemaEnumValues(schema: unknown): unknown[] { + return isObject(schema) && Array.isArray(schema.enum) ? schema.enum : []; +} + +function stripCompositionKeys(schema: JsonSchema): JsonSchema { + const stripped = { ...schema }; + for (const key of SCHEMA_COMPOSITION_KEYS) { + delete stripped[key]; + } + return stripped; +} + +function mergeSchemas( + schemas: JsonSchema[], + baseSchema?: JsonSchema | null +): JsonSchema { + const merged: JsonSchema = baseSchema ? stripCompositionKeys(baseSchema) : {}; + const properties: Record = {}; + const required = new Set(); + const enumValues: unknown[] = []; + const types = new Set(); + let items: unknown; + let additionalProperties: unknown; + + for (const schema of schemas) { + for (const type of getSchemaTypes(schema)) { + if (type !== 'null') { + types.add(type); + } + } + + for (const value of getSchemaEnumValues(schema)) { + if (!enumValues.some((candidate) => candidate === value)) { + enumValues.push(value); + } + } + + if (isObject(schema.properties)) { + Object.assign(properties, schema.properties); + } + + if (Array.isArray(schema.required)) { + for (const value of schema.required) { + if (typeof value === 'string') { + required.add(value); + } + } + } + + if (items === undefined && schema.items !== undefined) { + items = schema.items; + } + + if ( + additionalProperties === undefined && + schema.additionalProperties !== undefined + ) { + additionalProperties = schema.additionalProperties; + } + + if ( + merged.description === undefined && + typeof schema.description === 'string' + ) { + merged.description = schema.description; + } + + if (merged.title === undefined && typeof schema.title === 'string') { + merged.title = schema.title; + } + + if (merged.default === undefined && 'default' in schema) { + merged.default = schema.default; + } + + if ( + merged.examples === undefined && + Array.isArray(schema.examples) && + schema.examples.length > 0 + ) { + merged.examples = schema.examples; + } + } + + if (Object.keys(properties).length > 0) { + merged.properties = properties; + } + + if (required.size > 0) { + merged.required = [...required]; + } + + if (enumValues.length > 0) { + merged.enum = enumValues; + } + + if (types.size === 1) { + merged.type = [...types][0]; + } else if (types.size > 1) { + merged.type = [...types]; + } + + if (items !== undefined) { + merged.items = items; + } + + if (additionalProperties !== undefined) { + merged.additionalProperties = additionalProperties; + } + + return merged; +} + +function normalizeSchema( + schema: unknown, + rootSchema: JsonSchema | null, + seenRefs: Set = new Set() +): JsonSchema | null { + const current = asSchema(schema); + if (!current) { + return null; + } + + if (typeof current.$ref === 'string') { + const ref = current.$ref; + if (seenRefs.has(ref)) { + return stripCompositionKeys(current); + } + + const resolved = resolveJsonPointer(rootSchema, ref); + if (!resolved) { + return stripCompositionKeys(current); + } + + const localOverrides = stripCompositionKeys(current); + const nextSeenRefs = new Set(seenRefs); + nextSeenRefs.add(ref); + const normalizedResolved = normalizeSchema( + resolved, + rootSchema, + nextSeenRefs + ); + return normalizedResolved + ? mergeSchemas([normalizedResolved, localOverrides]) + : localOverrides; + } + + if (Array.isArray(current.allOf) && current.allOf.length > 0) { + const variants = current.allOf + .map((variant) => normalizeSchema(variant, rootSchema, seenRefs)) + .filter((variant): variant is JsonSchema => variant !== null); + + if (variants.length > 0) { + return mergeSchemas(variants, current); + } + } + + const union = Array.isArray(current.anyOf) + ? current.anyOf + : Array.isArray(current.oneOf) + ? current.oneOf + : null; + + if (union && union.length > 0) { + const variants = union + .map((variant) => normalizeSchema(variant, rootSchema, seenRefs)) + .filter((variant): variant is JsonSchema => variant !== null); + + const nonNullVariants = variants.filter( + (variant) => getSchemaType(variant) !== 'null' + ); + + if (nonNullVariants.length > 0) { + return mergeSchemas(nonNullVariants, current); + } + } + + return current; +} + +function getSchemaProperties(schema: unknown): Record { + const normalized = normalizeSchema(schema, asSchema(schema)); + if (!normalized) { + return {}; + } + + if (isObject(normalized.properties)) { + return normalized.properties; + } + + const propertyEntries = Object.entries(normalized).filter( + ([key, value]) => !RESERVED_SCHEMA_KEYS.has(key) && isObject(value) + ); + + return Object.fromEntries(propertyEntries); +} + +function getSchemaRequiredProperties(schema: unknown): string[] { + if (!isObject(schema) || !Array.isArray(schema.required)) { + return []; + } + + return schema.required.filter( + (value): value is string => typeof value === 'string' + ); +} + +function getSchemaAtProperty( + schema: JsonSchema | null, + propertyName: string, + rootSchema: JsonSchema | null +): JsonSchema | null { + const normalized = normalizeSchema(schema, rootSchema); + if (!normalized) { + return null; + } + + const properties = getSchemaProperties(normalized); + if (propertyName in properties) { + return normalizeSchema(properties[propertyName], rootSchema); + } + + if (isObject(normalized.additionalProperties)) { + return normalizeSchema(normalized.additionalProperties, rootSchema); + } + + return null; +} + +function getArrayItemSchema( + schema: JsonSchema | null, + rootSchema: JsonSchema | null +): JsonSchema | null { + const normalized = normalizeSchema(schema, rootSchema); + if (!normalized) { + return null; + } + + return normalizeSchema(normalized.items, rootSchema); +} + +function getPropertyKeyContext( + path: JsonPath, + isAtPropertyKey: boolean +): { objectPath: JsonPath; replaceExistingKey: boolean } | null { + if (!isAtPropertyKey || path.length === 0) { + return null; + } + + const last = path[path.length - 1]; + if (last === '') { + return { objectPath: path.slice(0, -1), replaceExistingKey: false }; + } + + if (typeof last === 'string') { + return { objectPath: path.slice(0, -1), replaceExistingKey: true }; + } + + return null; +} + +function isSelectorPathLocation(path: JsonPath): boolean { + return ( + path.length >= 2 && + path[path.length - 1] === 'path' && + path[path.length - 2] === 'selector' + ); +} + +function isEvaluatorNameLocation(path: JsonPath): boolean { + return ( + path.length >= 2 && + path[path.length - 1] === 'name' && + path[path.length - 2] === 'evaluator' + ); +} + +function escapeSnippetValue(value: string): string { + return value.replace(/[\\$}]/g, '\\$&'); +} + +function toJsonLiteral(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function getSchemaDescription(schema: unknown): string | undefined { + return isObject(schema) && typeof schema.description === 'string' + ? schema.description + : undefined; +} + +function getSchemaTitle(schema: unknown): string | undefined { + return isObject(schema) && typeof schema.title === 'string' + ? schema.title + : undefined; +} + +function isSchemaWithProperties( + schema: JsonSchema, + propertyNames: string[] +): boolean { + const properties = getSchemaProperties(schema); + return propertyNames.every((propertyName) => propertyName in properties); +} + +function getSchemaExamples(schema: unknown): unknown[] { + return isObject(schema) && Array.isArray(schema.examples) + ? schema.examples + : []; +} + +function getSchemaDefault(schema: unknown): unknown { + return isObject(schema) && 'default' in schema ? schema.default : undefined; +} + +function nextSnippetTabStop( + snippetState: SnippetState, + defaultValue?: string +): string { + const tabStop = snippetState.nextTabStop; + snippetState.nextTabStop += 1; + + if (defaultValue) { + return `\${${tabStop}:${escapeSnippetValue(defaultValue)}}`; + } + + return `\${${tabStop}}`; +} + +function getSuggestedObjectPropertyNames(schema: JsonSchema): string[] { + const properties = Object.keys(getSchemaProperties(schema)); + if (properties.length === 0) { + return []; + } + + const required = getSchemaRequiredProperties(schema); + if (required.length > 0) { + return required.filter((propertyName) => properties.includes(propertyName)); + } + + if (properties.length === 1) { + return properties; + } + + return []; +} + +function buildSchemaValueSnippet( + schema: JsonSchema | null, + rootSchema: JsonSchema | null, + snippetState: SnippetState, + depth = 0 +): string { + const normalized = normalizeSchema(schema, rootSchema); + if (!normalized || depth > 4) { + return nextSnippetTabStop(snippetState); + } + + const enumValues = getSchemaEnumValues(normalized); + if (enumValues.length > 0) { + return toJsonLiteral(enumValues[0]); + } + + const examples = getSchemaExamples(normalized); + const defaultValue = getSchemaDefault(normalized); + const preferredValue = + defaultValue !== undefined ? defaultValue : examples[0]; + const schemaTitle = getSchemaTitle(normalized); + + if ( + schemaTitle === 'ControlSelector' || + isSchemaWithProperties(normalized, ['path']) + ) { + return '{\n "path": "*"\n}'; + } + + if ( + schemaTitle === 'EvaluatorSpec' || + isSchemaWithProperties(normalized, ['name', 'config']) + ) { + return '{\n "name": "",\n "config": {}\n}'; + } + + if ( + schemaTitle === 'ControlAction' || + isSchemaWithProperties(normalized, ['decision', 'steering_context']) + ) { + return '{\n "decision": "deny"\n}'; + } + + if ( + schemaTitle === 'ControlScope' || + isSchemaWithProperties(normalized, ['step_types', 'stages']) + ) { + return '{\n "step_types": ["llm"],\n "stages": ["post"]\n}'; + } + + if ( + schemaTitle === 'ConditionNode' || + isSchemaWithProperties(normalized, [ + 'selector', + 'evaluator', + 'and', + 'or', + 'not', + ]) + ) { + return '{}'; + } + + switch (getSchemaType(normalized)) { + case 'object': { + return '{}'; + } + case 'array': { + return '[]'; + } + case 'boolean': { + return String( + typeof preferredValue === 'boolean' ? preferredValue : true + ); + } + case 'integer': + case 'number': { + return String(typeof preferredValue === 'number' ? preferredValue : 0); + } + case 'string': { + if (typeof preferredValue === 'string' && preferredValue.length > 0) { + return `"${escapeSnippetValue(preferredValue)}"`; + } + return '""'; + } + default: { + if (preferredValue !== undefined) { + return toJsonLiteral(preferredValue); + } + return 'null'; + } + } +} + +function buildPropertyInsertText( + propertyName: string, + propertySchema: JsonSchema | null, + rootSchema: JsonSchema | null, + replaceExistingKey = false +): string { + const snippetState: SnippetState = { nextTabStop: 1 }; + const valueSnippet = buildSchemaValueSnippet( + propertySchema, + rootSchema, + snippetState + ); + const prefix = replaceExistingKey + ? `${escapeSnippetValue(propertyName)}": ` + : `"${escapeSnippetValue(propertyName)}": `; + return `${prefix}${valueSnippet}`; +} + +function buildValueInsertText( + value: unknown, + isStringValueContext: boolean +): string { + return typeof value === 'string' && isStringValueContext + ? value + : toJsonLiteral(value); +} + +function getObjectPropertyNames(node: JsonNode | undefined): Set { + if (!node || node.type !== 'object' || !node.children) { + return new Set(); + } + + return new Set( + node.children + .map((propertyNode) => { + const keyNode = propertyNode.children?.[0]; + return typeof keyNode?.value === 'string' ? keyNode.value : null; + }) + .filter((value): value is string => value !== null) + ); +} + +function getExistingKeysFromText(text: string, offset: number): Set { + let braceDepth = 0; + let objectStart = -1; + for (let i = offset - 1; i >= 0; i -= 1) { + if (text[i] === '}') braceDepth += 1; + if (text[i] === '{') { + if (braceDepth === 0) { + objectStart = i; + break; + } + braceDepth -= 1; + } + } + if (objectStart < 0) return new Set(); + + braceDepth = 0; + let objectEnd = text.length; + for (let i = objectStart; i < text.length; i += 1) { + if (text[i] === '{') braceDepth += 1; + if (text[i] === '}') { + braceDepth -= 1; + if (braceDepth === 0) { + objectEnd = i; + break; + } + } + } + + const keys = new Set(); + const pattern = /"([^"]+)"\s*:/g; + let match; + const slice = text.substring(objectStart, objectEnd + 1); + while ((match = pattern.exec(slice)) !== null) { + keys.add(match[1]); + } + return keys; +} + +function walkSchemaPaths( + schema: unknown, + basePath: string, + output: Set, + depth = 0 +) { + if (depth > 5) { + return; + } + + output.add(basePath); + const properties = getSchemaProperties(schema); + for (const [propertyName, propertySchema] of Object.entries(properties)) { + const childPath = `${basePath}.${propertyName}`; + output.add(childPath); + walkSchemaPaths(propertySchema, childPath, output, depth + 1); + } +} + +function buildSelectorPathSuggestions( + steps: StepSchema[] | undefined, + tree: JsonNode | undefined +): SelectorPathSuggestion[] { + const suggestions = new Map(); + const { stepTypes, stepNames } = getScopeFilters(tree); + const rankedSteps = steps ?? []; + + for (const rootPath of ROOT_SELECTOR_PATHS) { + suggestions.set(rootPath, { + label: rootPath, + detail: 'Built-in control selector root', + rank: 0, + }); + } + + const getStepRank = (step: StepSchema): number => { + const typeMatches = stepTypes.length === 0 || stepTypes.includes(step.type); + const nameMatches = stepNames.length === 0 || stepNames.includes(step.name); + + if (typeMatches && nameMatches) return 0; + if (typeMatches || nameMatches) return 1; + return 2; + }; + + for (const step of rankedSteps) { + const rank = getStepRank(step); + const stepLabel = `${step.type}:${step.name}`; + const inputPaths = new Set(['input']); + const outputPaths = new Set(['output']); + + if (step.input_schema) { + walkSchemaPaths(step.input_schema, 'input', inputPaths); + } + + if (step.output_schema) { + walkSchemaPaths(step.output_schema, 'output', outputPaths); + } + + for (const path of [...inputPaths, ...outputPaths]) { + const existing = suggestions.get(path); + if (!existing || rank < existing.rank) { + suggestions.set(path, { + label: path, + detail: stepLabel, + rank, + }); + } + } + } + + return [...suggestions.values()].sort((left, right) => { + if (left.rank !== right.rank) { + return left.rank - right.rank; + } + return left.label.localeCompare(right.label); + }); +} + +function findEvaluatorById( + evaluators: JsonEditorEvaluatorOption[] | undefined, + id: string | null | undefined +): JsonEditorEvaluatorOption | null { + if (!evaluators || !id) { + return null; + } + + return evaluators.find((candidate) => candidate.id === id) ?? null; +} + +function resolveActiveEvaluator( + context: JsonEditorAutocompleteContext, + tree: JsonNode | undefined, + path: JsonPath +): JsonEditorEvaluatorOption | null { + if (context.mode === 'evaluator-config') { + return findEvaluatorById(context.evaluators, context.activeEvaluatorId); + } + + const evaluatorIndex = getJsonPathFieldIndex(path, 'evaluator'); + if (!tree || evaluatorIndex < 0) { + return null; + } + + const evaluatorNamePath = [ + ...path.slice(0, evaluatorIndex), + 'evaluator', + 'name', + ]; + const evaluatorNameNode = findNodeAtLocation(tree, evaluatorNamePath); + const evaluatorName = + typeof evaluatorNameNode?.value === 'string' + ? evaluatorNameNode.value + : null; + + return findEvaluatorById(context.evaluators, evaluatorName); +} + +function getInitialSchemaCursor( + context: JsonEditorAutocompleteContext, + activeEvaluator: JsonEditorEvaluatorOption | null +): SchemaCursor { + if (context.mode === 'evaluator-config') { + const rootSchema = asSchema(activeEvaluator?.configSchema ?? null); + return { + schema: normalizeSchema(rootSchema, rootSchema), + rootSchema, + }; + } + + const rootSchema = asSchema(context.schema ?? null); + return { + schema: normalizeSchema(rootSchema, rootSchema), + rootSchema, + }; +} + +function isEvaluatorConfigSegment(path: JsonPath, index: number): boolean { + return ( + typeof path[index] === 'string' && + path[index] === 'config' && + index > 0 && + path[index - 1] === 'evaluator' + ); +} + +function resolveSchemaAtJsonPath( + context: JsonEditorAutocompleteContext, + activeEvaluator: JsonEditorEvaluatorOption | null, + path: JsonPath +): SchemaCursor { + let cursor = getInitialSchemaCursor(context, activeEvaluator); + + for (let index = 0; index < path.length; index += 1) { + const segment = path[index]; + if (!cursor.schema) { + return cursor; + } + + if (context.mode === 'control' && isEvaluatorConfigSegment(path, index)) { + const rootSchema = asSchema(activeEvaluator?.configSchema ?? null); + cursor = { + schema: normalizeSchema(rootSchema, rootSchema), + rootSchema, + }; + continue; + } + + if (typeof segment === 'number') { + cursor = { + schema: getArrayItemSchema(cursor.schema, cursor.rootSchema), + rootSchema: cursor.rootSchema, + }; + continue; + } + + cursor = { + schema: getSchemaAtProperty(cursor.schema, segment, cursor.rootSchema), + rootSchema: cursor.rootSchema, + }; + } + + return cursor; +} + +function buildEvaluatorNameSuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + evaluators: JsonEditorEvaluatorOption[] | undefined, + isStringValueContext: boolean +) { + return (evaluators ?? []).map((evaluator, index) => ({ + label: evaluator.id, + kind: monaco.languages.CompletionItemKind.Value, + detail: + evaluator.source === 'agent' + ? `${evaluator.label} (agent evaluator)` + : evaluator.label, + documentation: evaluator.description ?? undefined, + insertText: buildValueInsertText(evaluator.id, isStringValueContext), + range, + sortText: `!0${index.toString().padStart(3, '0')}`, + })); +} + +function buildSelectorSuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + steps: StepSchema[] | undefined, + tree: JsonNode | undefined, + isStringValueContext: boolean +) { + return buildSelectorPathSuggestions(steps, tree).map((suggestion, index) => ({ + label: suggestion.label, + kind: monaco.languages.CompletionItemKind.Value, + detail: suggestion.detail, + insertText: buildValueInsertText(suggestion.label, isStringValueContext), + range, + sortText: `!${suggestion.rank}${index.toString().padStart(3, '0')}`, + })); +} + +function buildSchemaPropertySuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + schemaCursor: SchemaCursor, + tree: JsonNode | undefined, + objectPath: JsonPath, + replaceExistingKey: boolean, + currentPropertyName: string | null, + text: string, + offset: number +) { + if (!schemaCursor.schema) { + return []; + } + + const objectNode = tree ? findNodeAtLocation(tree, objectPath) : undefined; + // Use AST-based key detection, with text-based fallback for broken JSON + const existingKeys = objectNode + ? getObjectPropertyNames(objectNode) + : getExistingKeysFromText(text, offset); + if (currentPropertyName) { + existingKeys.delete(currentPropertyName); + } + + return Object.entries(getSchemaProperties(schemaCursor.schema)) + .filter( + ([propertyName]) => + !existingKeys.has(propertyName) && !propertyName.startsWith('$') + ) + .map(([propertyName, propertySchema], index) => ({ + label: propertyName, + kind: monaco.languages.CompletionItemKind.Property, + detail: getSchemaDescription(propertySchema), + documentation: getSchemaDescription(propertySchema), + insertText: buildPropertyInsertText( + propertyName, + asSchema(propertySchema), + schemaCursor.rootSchema, + replaceExistingKey + ), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: `!1${index.toString().padStart(3, '0')}`, + })); +} + +function buildSchemaValueSuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + schemaCursor: SchemaCursor, + isStringValueContext: boolean +) { + const schema = schemaCursor.schema; + if (!schema) { + return []; + } + + const suggestions: import('monaco-editor').languages.CompletionItem[] = []; + const enumValues = getSchemaEnumValues(schema); + + if (enumValues.length > 0) { + suggestions.push( + ...enumValues.map((value, index) => ({ + label: String(value), + kind: monaco.languages.CompletionItemKind.Value, + detail: getSchemaTitle(schema) ?? getSchemaDescription(schema), + insertText: buildValueInsertText(value, isStringValueContext), + range, + sortText: `!2${index.toString().padStart(3, '0')}`, + })) + ); + return suggestions; + } + + const schemaType = getSchemaType(schema); + if (schemaType === 'boolean') { + suggestions.push( + ...['true', 'false'].map((value, index) => ({ + label: value, + kind: monaco.languages.CompletionItemKind.Value, + detail: getSchemaTitle(schema) ?? getSchemaDescription(schema), + insertText: value, + range, + sortText: `!2${index.toString().padStart(3, '0')}`, + })) + ); + return suggestions; + } + + const preferredValues = [ + getSchemaDefault(schema), + ...getSchemaExamples(schema), + ].filter((value, index, collection) => { + if (value === undefined || value === null) { + return false; + } + + return collection.findIndex((candidate) => candidate === value) === index; + }); + + for (const [index, value] of preferredValues.entries()) { + suggestions.push({ + label: typeof value === 'string' ? value : toJsonLiteral(value), + kind: monaco.languages.CompletionItemKind.Value, + detail: 'Schema example', + insertText: buildValueInsertText(value, isStringValueContext), + range, + sortText: `!3${index.toString().padStart(3, '0')}`, + }); + } + + if (schemaType === 'object' || schemaType === 'array') { + const snippetState: SnippetState = { nextTabStop: 1 }; + suggestions.push({ + label: schemaType === 'object' ? 'object' : 'array', + kind: monaco.languages.CompletionItemKind.Snippet, + detail: + schemaType === 'object' + ? 'Insert an object matching the schema' + : 'Insert an array matching the schema', + documentation: getSchemaDescription(schema), + insertText: buildSchemaValueSnippet( + schema, + schemaCursor.rootSchema, + snippetState + ), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: '!4schema', + }); + } + + return suggestions; +} + +function getCompletionLabel( + item: import('monaco-editor').languages.CompletionItem +): string { + return typeof item.label === 'string' ? item.label : item.label.label; +} + +function dedupeSuggestions( + suggestions: import('monaco-editor').languages.CompletionItem[] +) { + const seen = new Set(); + + return suggestions.filter((item) => { + const key = `${getCompletionLabel(item)}::${String(item.insertText ?? '')}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function buildCompletionSuggestions( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position, + context: JsonEditorAutocompleteContext +): import('monaco-editor').languages.CompletionItem[] { + const text = model.getValue(); + const offset = model.getOffsetAt(position); + const tree = parseTree(text); + const location = getLocation(text, offset); + const node = + tree && offset > 0 ? findNodeAtOffset(tree, offset - 1, true) : tree; + const valueRange = getReplaceRange(monaco, model, position, node); + const isStringValueContext = + node?.type === 'string' && !location.isAtPropertyKey; + const suggestions: import('monaco-editor').languages.CompletionItem[] = []; + + const activeEvaluator = resolveActiveEvaluator(context, tree, location.path); + + if (isEvaluatorNameLocation(location.path)) { + suggestions.push( + ...buildEvaluatorNameSuggestions( + monaco, + valueRange, + context.evaluators, + isStringValueContext + ) + ); + } + + if (isSelectorPathLocation(location.path)) { + suggestions.push( + ...buildSelectorSuggestions( + monaco, + valueRange, + context.steps, + tree, + isStringValueContext + ) + ); + } + + const propertyKeyContext = getPropertyKeyContext( + location.path, + location.isAtPropertyKey + ); + + if (propertyKeyContext) { + // Only treat as replacing when cursor is inside a quoted string node. + // For bare text (typing without "), we need the leading " in the insert. + const hasStringNode = node?.type === 'string'; + const replaceExistingKey = hasStringNode; + + const propertyRange = + (hasStringNode + ? getPropertyKeyReplaceRange(monaco, model, node) + : null) ?? getDefaultRange(monaco, model, position); + const schemaCursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + propertyKeyContext.objectPath + ); + const currentPropertyName = + replaceExistingKey && typeof node?.value === 'string' ? node.value : null; + + suggestions.push( + ...buildSchemaPropertySuggestions( + monaco, + propertyRange, + schemaCursor, + tree, + propertyKeyContext.objectPath, + replaceExistingKey, + currentPropertyName, + text, + offset + ) + ); + } + + // Only show value suggestions at actual value positions — not on blank lines, + // closing brackets, or property key positions where they're confusing noise. + const lineText = model.getLineContent(position.lineNumber); + const isValuePosition = + !propertyKeyContext && !location.isAtPropertyKey && isStringValueContext; + if (isValuePosition) { + const valueSchemaCursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + location.path + ); + + suggestions.push( + ...buildSchemaValueSuggestions( + monaco, + valueRange, + valueSchemaCursor, + isStringValueContext + ) + ); + } + + return dedupeSuggestions(suggestions); +} + +export function fixJsonCommas(text: string): string { + // 1. Remove trailing commas before } or ] without touching string literals. + let fixed = removeTrailingCommasOutsideStrings(text); + + // 2. Insert missing commas (detected by jsonc-parser) + const errors: ParseError[] = []; + parseTree(fixed, errors); + + const commaErrors = errors + .filter((e) => e.error === 6 /* CommaExpected */) + .sort((a, b) => b.offset - a.offset); + + for (const error of commaErrors) { + // Insert comma at end of previous value (before whitespace), not at + // the start of the next token where jsonc-parser reports the error. + let insertAt = error.offset; + while (insertAt > 0 && /\s/.test(fixed[insertAt - 1])) { + insertAt -= 1; + } + fixed = fixed.slice(0, insertAt) + ',' + fixed.slice(insertAt); + } + return fixed; +} + +export function getJsonEditorCompletionItems( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position, + context: JsonEditorAutocompleteContext +) { + return buildCompletionSuggestions(monaco, model, position, context); +} + +type EvaluatorNodeInfo = { + name: string; + nameNode: JsonNode; + configNode: JsonNode | undefined; + evaluatorNode: JsonNode; +}; + +function collectEvaluatorNames( + node: JsonNode | undefined, + result: Map +) { + if (!node || node.type !== 'object' || !node.children) return; + + const evaluatorNode = findNodeAtLocation(node, ['evaluator']); + if (evaluatorNode?.type === 'object') { + const nameNode = findNodeAtLocation(evaluatorNode, ['name']); + const configNode = findNodeAtLocation(evaluatorNode, ['config']); + if (nameNode && typeof nameNode.value === 'string') { + result.set(`${nameNode.offset}`, { + name: nameNode.value, + nameNode, + configNode, + evaluatorNode, + }); + } + } + + for (const key of ['and', 'or'] as const) { + const arrayNode = findNodeAtLocation(node, [key]); + if (arrayNode?.type === 'array' && arrayNode.children) { + for (const child of arrayNode.children) { + collectEvaluatorNames(child, result); + } + } + } + + const notNode = findNodeAtLocation(node, ['not']); + if (notNode?.type === 'object') { + collectEvaluatorNames(notNode, result); + } +} + +export function extractEvaluatorNames(text: string): Map { + const tree = parseTree(text); + if (!tree) return new Map(); + + const conditionNode = findNodeAtLocation(tree, ['condition']); + const result = new Map(); + collectEvaluatorNames(conditionNode, result); + + const names = new Map(); + for (const [key, info] of result) { + names.set(key, info.name); + } + return names; +} + +function getDefaultValueForSchema(propSchema: JsonSchema): unknown { + const defaultValue = getSchemaDefault(propSchema); + if (defaultValue !== undefined) return defaultValue; + + const enumValues = getSchemaEnumValues(propSchema); + if (enumValues.length > 0) return enumValues[0]; + + switch (getSchemaType(propSchema)) { + case 'string': + return ''; + case 'number': + case 'integer': + return 0; + case 'boolean': + return false; + case 'array': + return []; + case 'object': + return {}; + default: + return null; + } +} + +export function buildDefaultConfig( + configSchema: unknown +): Record { + const schema = asSchema(configSchema); + if (!schema) return {}; + + const normalized = normalizeSchema(schema, schema); + if (!normalized) return {}; + + const properties = getSchemaProperties(normalized); + const required = new Set(getSchemaRequiredProperties(normalized)); + const config: Record = {}; + + // Include ALL properties — required ones get type-appropriate defaults, + // optional ones with explicit defaults get those defaults. + for (const [propName, rawPropSchema] of Object.entries(properties)) { + const propSchema = normalizeSchema(rawPropSchema, schema); + if (!propSchema) continue; + + const explicitDefault = getSchemaDefault(propSchema); + if (required.has(propName) || explicitDefault !== undefined) { + config[propName] = getDefaultValueForSchema(propSchema); + } + } + + return config; +} + +export function findEvaluatorConfigEdit( + text: string, + previousNames: Map, + evaluators: JsonEditorEvaluatorOption[] | undefined +): { offset: number; length: number; newText: string } | null { + const tree = parseTree(text); + if (!tree) return null; + + const conditionNode = findNodeAtLocation(tree, ['condition']); + const result = new Map(); + collectEvaluatorNames(conditionNode, result); + + for (const [key, { name, configNode, nameNode }] of result) { + const prevName = previousNames.get(key); + if (prevName === undefined || prevName === name) continue; + + const evaluator = evaluators?.find((e) => e.id === name); + if (!evaluator) continue; + + const defaultConfig = buildDefaultConfig(evaluator.configSchema); + const configJson = JSON.stringify(defaultConfig, null, 2); + + if (configNode) { + // Replace existing config + return { + offset: configNode.offset, + length: configNode.length, + newText: configJson, + }; + } + + // No config property yet — insert after the name property. + // Find the end of the "name": "value" property in the source text. + const nameEnd = nameNode.offset + nameNode.length; + return { + offset: nameEnd, + length: 0, + newText: `,\n"config": ${configJson}`, + }; + } + + return null; +} + +export function findSteeringContextEdit( + text: string, + previousDecision: string | null +): { offset: number; length: number; newText: string } | null { + const tree = parseTree(text); + if (!tree) return null; + + const decisionNode = findNodeAtLocation(tree, ['action', 'decision']); + if (!decisionNode || typeof decisionNode.value !== 'string') return null; + + const currentDecision = decisionNode.value; + if (currentDecision === previousDecision) return null; + + if (currentDecision === 'steer') { + // Add steering_context if missing + const steeringNode = findNodeAtLocation(tree, [ + 'action', + 'steering_context', + ]); + if (!steeringNode) { + const decisionEnd = decisionNode.offset + decisionNode.length; + return { + offset: decisionEnd, + length: 0, + newText: `,\n"steering_context": {"message": "Please correct your response."}`, + }; + } + } else if (previousDecision === 'steer') { + // Remove steering_context when switching away from steer + const actionNode = findNodeAtLocation(tree, ['action']); + if (actionNode?.type === 'object' && actionNode.children) { + for (const prop of actionNode.children) { + const key = prop.children?.[0]; + if (key?.value === 'steering_context') { + // Find range including the preceding comma + let start = prop.offset; + while (start > 0 && /[\s,]/.test(text[start - 1])) { + start -= 1; + } + return { + offset: start, + length: prop.offset + prop.length - start, + newText: '', + }; + } + } + } + } + + return null; +} + +const MAX_HINT_VALUES = 6; + +function getStringValueAtPath( + tree: JsonNode | undefined, + path: JsonPath +): string | null { + if (!tree) return null; + const node = findNodeAtLocation(tree, path); + return typeof node?.value === 'string' ? node.value : null; +} + +export function getEmptyValueHints( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + context: JsonEditorAutocompleteContext +): Array<{ range: import('monaco-editor').IRange; hint: string }> { + const text = model.getValue(); + const tree = parseTree(text); + if (!tree) return []; + + const hints: Array<{ range: import('monaco-editor').IRange; hint: string }> = + []; + + // Hints for empty string values + const emptyStringPattern = /:\s*""/g; + let match; + + while ((match = emptyStringPattern.exec(text)) !== null) { + const offset = match.index + match[0].length - 1; + const location = getLocation(text, offset); + if (location.isAtPropertyKey) continue; + + const pos = model.getPositionAt(offset); + const range = new monaco.Range( + pos.lineNumber, + pos.column, + pos.lineNumber, + pos.column + ); + + const activeEvaluator = resolveActiveEvaluator( + context, + tree, + location.path + ); + + if (isEvaluatorNameLocation(location.path) && context.evaluators?.length) { + const names = context.evaluators.map((e) => e.id); + const display = names.slice(0, MAX_HINT_VALUES); + const hint = + display.join(' | ') + + (names.length > MAX_HINT_VALUES ? ' | ...' : ''); + hints.push({ range, hint: ` ${hint}` }); + continue; + } + + if (isSelectorPathLocation(location.path)) { + hints.push({ + range, + hint: ' * | input | output | context | ...', + }); + continue; + } + + const schemaCursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + location.path + ); + if (!schemaCursor.schema) continue; + + const enumValues = getSchemaEnumValues(schemaCursor.schema); + if (enumValues.length > 0 && enumValues.length <= MAX_HINT_VALUES) { + hints.push({ + range, + hint: ` ${enumValues.map(String).join(' | ')}`, + }); + } + } + + return hints; +} + +// Default Monaco JSON mode configuration with completionItems disabled. +// We disable the built-in JSON completion provider to avoid duplicate suggestions +export function setupJsonEditorLanguageSupport( + monaco: MonacoModule, + context: JsonEditorAutocompleteContext +) { + const jsonDefaults = ( + monaco.languages.json as unknown as { + jsonDefaults?: { + setDiagnosticsOptions: (options: { + validate: boolean; + allowComments: boolean; + schemas: Array<{ + fileMatch: string[]; + uri: string; + schema: JsonSchema; + }>; + }) => void; + }; + } + ).jsonDefaults; + + // Validate JSON syntax only — don't pass schema to avoid Monaco's built-in + // completions duplicating our custom suggestions. Monaco 0.55's + // setModeConfiguration({ completionItems: false }) doesn't reliably disable + // the built-in provider. Server-side validation handles schema errors. + jsonDefaults?.setDiagnosticsOptions({ + validate: true, + allowComments: false, + schemas: [], + }); + + const hoverDisposable = monaco.languages.registerHoverProvider('json', { + provideHover(model, position) { + if (model.uri.toString() !== context.modelUri) return null; + if (!context.schema) return null; + + const text = model.getValue(); + const offset = model.getOffsetAt(position); + const tree = parseTree(text); + const location = getLocation(text, offset); + if (!location.path.length) return null; + + const rootSchema = asSchema(context.schema); + const activeEvaluator = resolveActiveEvaluator( + context, + tree, + location.path + ); + const cursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + location.isAtPropertyKey ? location.path.slice(0, -1) : location.path + ); + + // For property keys, show the property's schema description + if (location.isAtPropertyKey) { + const propName = location.path[location.path.length - 1]; + if (typeof propName !== 'string' || !cursor.schema) return null; + const propSchema = getSchemaAtProperty( + cursor.schema, + propName, + cursor.rootSchema + ); + const desc = getSchemaDescription(propSchema); + const title = getSchemaTitle(propSchema); + if (!desc && !title) return null; + + const word = model.getWordAtPosition(position); + const range = word + ? new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ) + : undefined; + + return { + range, + contents: [ + { + value: `**${title ?? propName}**${desc ? '\n\n' + desc : ''}`, + }, + ], + }; + } + + // For values, show the value's schema info + if (cursor.schema) { + const desc = getSchemaDescription(cursor.schema); + const title = getSchemaTitle(cursor.schema); + const enumVals = getSchemaEnumValues(cursor.schema); + if (!desc && !title && enumVals.length === 0) return null; + + const parts: string[] = []; + if (title) parts.push(`**${title}**`); + if (desc) parts.push(desc); + if (enumVals.length > 0) + parts.push(`Values: \`${enumVals.join('` | `')}\``); + + const word = model.getWordAtPosition(position); + const range = word + ? new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ) + : undefined; + + return { + range, + contents: [{ value: parts.join('\n\n') }], + }; + } + + return null; + }, + }); + + const disposable = monaco.languages.registerCompletionItemProvider('json', { + triggerCharacters: COMPLETION_TRIGGER_CHARACTERS, + provideCompletionItems(model, position) { + if (model.uri.toString() !== context.modelUri) { + return { suggestions: [] }; + } + + return { + suggestions: getJsonEditorCompletionItems( + monaco, + model, + position, + context + ), + }; + }, + }); + + const codeActionDisposable = registerConditionCodeActions(monaco, context); + + return () => { + hoverDisposable.dispose(); + disposable.dispose(); + codeActionDisposable.dispose(); + }; +} + +// --------------------------------------------------------------------------- +// Condition Code Actions (lightbulb refactoring) +// --------------------------------------------------------------------------- + +const LEAF_CONDITION_TEMPLATE = { + selector: { path: '*' }, + evaluator: { name: '', config: {} }, +}; + +function findConditionNodeAtOffset( + tree: JsonNode | undefined, + offset: number +): { + node: JsonNode; + isLeaf: boolean; + isArray: boolean; + arrayKey: string | null; +} | null { + if (!tree) return null; + + const conditionNode = findNodeAtLocation(tree, ['condition']); + if (!conditionNode) return null; + + return findConditionAtOffset(conditionNode, offset); +} + +function findConditionAtOffset( + node: JsonNode, + offset: number +): { + node: JsonNode; + isLeaf: boolean; + isArray: boolean; + arrayKey: string | null; +} | null { + if (offset < node.offset || offset > node.offset + node.length) return null; + + if (node.type === 'object' && node.children) { + for (const prop of node.children) { + const key = prop.children?.[0]?.value; + const value = prop.children?.[1]; + if (!value) continue; + + if (key === 'and' || key === 'or') { + if (value.type === 'array' && value.children) { + // Check if offset is inside an array item + for (const item of value.children) { + const inner = findConditionAtOffset(item, offset); + if (inner) return inner; + } + // Offset is in the array but not inside a specific item + if (offset >= value.offset && offset <= value.offset + value.length) { + return { + node, + isLeaf: false, + isArray: true, + arrayKey: key as string, + }; + } + } + } else if (key === 'not' && value.type === 'object') { + const inner = findConditionAtOffset(value, offset); + if (inner) return inner; + } + } + + // We're on this object node itself + const hasSelector = !!findNodeAtLocation(node, ['selector']); + const hasEvaluator = !!findNodeAtLocation(node, ['evaluator']); + const hasAnd = !!findNodeAtLocation(node, ['and']); + const hasOr = !!findNodeAtLocation(node, ['or']); + const hasNot = !!findNodeAtLocation(node, ['not']); + const isLeaf = (hasSelector || hasEvaluator) && !hasAnd && !hasOr; + + return { + node, + isLeaf, + isArray: false, + arrayKey: hasAnd ? 'and' : hasOr ? 'or' : hasNot ? 'not' : null, + }; + } + + return null; +} + +function registerConditionCodeActions( + monaco: MonacoModule, + context: JsonEditorAutocompleteContext +) { + return monaco.languages.registerCodeActionProvider('json', { + provideCodeActions(model, range) { + if (model.uri.toString() !== context.modelUri) + return { actions: [], dispose() {} }; + if (context.mode !== 'control') return { actions: [], dispose() {} }; + + const text = model.getValue(); + const tree = parseTree(text); + if (!tree) return { actions: [], dispose() {} }; + + const offset = model.getOffsetAt(range.getStartPosition()); + const condCtx = findConditionNodeAtOffset(tree, offset); + if (!condCtx) return { actions: [], dispose() {} }; + + const actions: import('monaco-editor').languages.CodeAction[] = []; + const { node, isLeaf, isArray, arrayKey } = condCtx; + + const candidates: ( + | import('monaco-editor').languages.CodeAction + | null + )[] = []; + + if (isLeaf) { + candidates.push( + buildNodeTransformAction( + monaco, + model, + node, + 'Wrap in AND (add another condition)', + (p) => ({ and: [p, LEAF_CONDITION_TEMPLATE] }) + ), + buildNodeTransformAction( + monaco, + model, + node, + 'Wrap in OR (add another condition)', + (p) => ({ or: [p, LEAF_CONDITION_TEMPLATE] }) + ), + buildNodeTransformAction(monaco, model, node, 'Wrap in NOT', (p) => ({ + not: p, + })) + ); + } + + if (isArray && (arrayKey === 'and' || arrayKey === 'or')) { + const otherKey = arrayKey === 'and' ? 'or' : 'and'; + candidates.push( + buildNodeTransformAction( + monaco, + model, + node, + `Add condition to ${arrayKey.toUpperCase()}`, + (p) => { + const o = p as Record; + const a = o[arrayKey]; + if (!Array.isArray(a)) return undefined; + return { ...o, [arrayKey]: [...a, LEAF_CONDITION_TEMPLATE] }; + } + ), + buildNodeTransformAction( + monaco, + model, + node, + `Convert ${arrayKey.toUpperCase()} to ${otherKey.toUpperCase()}`, + (p) => { + const o = p as Record; + const a = o[arrayKey]; + delete o[arrayKey]; + return { ...o, [otherKey]: a }; + } + ) + ); + } + + if (arrayKey === 'not') { + candidates.push( + buildNodeTransformAction( + monaco, + model, + node, + 'Remove NOT (unwrap)', + (p) => (p as Record).not + ) + ); + } + + for (const action of candidates) { + if (action) actions.push(action); + } + + return { actions, dispose() {} }; + }, + }); +} + +function buildNodeTransformAction( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + node: JsonNode, + title: string, + transform: (parsed: unknown) => unknown +): import('monaco-editor').languages.CodeAction | null { + // Parse the full document, apply the transform to the target node, + // then re-serialize the whole document. This produces a single edit + // that replaces the entire content with properly formatted JSON, + // making undo a clean single-step revert. + const fullText = model.getValue(); + const nodeText = fullText.substring(node.offset, node.offset + node.length); + let parsed: unknown; + try { + parsed = JSON.parse(nodeText); + } catch { + return null; + } + + const result = transform(parsed); + if (result === undefined) return null; + + // Rebuild full document with the transformed node + const newNodeText = JSON.stringify(result); + const rawDoc = + fullText.substring(0, node.offset) + + newNodeText + + fullText.substring(node.offset + node.length); + + let newText: string; + try { + newText = JSON.stringify(JSON.parse(rawDoc), null, 2); + } catch { + // Fallback: just replace the node + newText = + fullText.substring(0, node.offset) + + JSON.stringify(result, null, 2) + + fullText.substring(node.offset + node.length); + } + + const fullRange = model.getFullModelRange(); + + return { + title, + kind: 'refactor', + edit: { + edits: [ + { + resource: model.uri, + textEdit: { + range: new monaco.Range( + fullRange.startLineNumber, + fullRange.startColumn, + fullRange.endLineNumber, + fullRange.endColumn + ), + text: newText, + }, + versionId: model.getVersionId(), + }, + ], + }, + }; +} diff --git a/ui/src/components/json-editor-monaco/json-editor-monaco.tsx b/ui/src/components/json-editor-monaco/json-editor-monaco.tsx new file mode 100644 index 00000000..480ec163 --- /dev/null +++ b/ui/src/components/json-editor-monaco/json-editor-monaco.tsx @@ -0,0 +1,6 @@ +import { JsonEditorView } from '@/core/page-components/agent-detail/modals/edit-control/json-editor-view'; +import type { JsonEditorViewProps } from '@/core/page-components/agent-detail/modals/edit-control/types'; + +export function JsonEditorMonaco(props: JsonEditorViewProps) { + return ; +} diff --git a/ui/src/components/json-editor-shared/fix-json-commas.ts b/ui/src/components/json-editor-shared/fix-json-commas.ts new file mode 100644 index 00000000..2b6d7585 --- /dev/null +++ b/ui/src/components/json-editor-shared/fix-json-commas.ts @@ -0,0 +1,37 @@ +export function removeTrailingCommasOutsideStrings(text: string): string { + let fixed = ''; + let inString = false; + let escaped = false; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + + if (char === '"' && !escaped) { + inString = !inString; + fixed += char; + continue; + } + + if (inString) { + escaped = char === '\\' ? !escaped : false; + fixed += char; + continue; + } + + if (char === ',') { + let lookahead = index + 1; + while (lookahead < text.length && /\s/.test(text[lookahead] ?? '')) { + lookahead += 1; + } + + const next = text[lookahead]; + if (next === '}' || next === ']') { + continue; + } + } + + fixed += char; + } + + return fixed; +} diff --git a/ui/src/core/api/client.ts b/ui/src/core/api/client.ts index 6fec427a..a4ac22d8 100644 --- a/ui/src/core/api/client.ts +++ b/ui/src/core/api/client.ts @@ -5,6 +5,7 @@ import type { CreateControlRequest, GetAgentControlsPathParams, GetAgentPathParams, + GetControlSchemaResponse, InitAgentRequestBody, ListAgentsQueryParams, PatchControlRequest, @@ -168,6 +169,12 @@ export const api = { }), create: (data: CreateControlRequest) => apiClient.PUT('/api/v1/controls', { body: data }), + getSchema: () => + apiClient.GET('/api/v1/controls/schema') as Promise<{ + data?: GetControlSchemaResponse; + error?: unknown; + response: Response; + }>, getData: (controlId: number) => apiClient.GET('/api/v1/controls/{control_id}/data', { params: { path: { control_id: controlId } }, @@ -189,17 +196,14 @@ export const api = { data: ValidateControlDataRequest['data']; signal?: AbortSignal; }) => - // TODO: remove cast after regenerating api types - ( - apiClient.POST as unknown as ( - path: '/api/v1/controls/validate', - init: { body: ValidateControlDataRequest; signal?: AbortSignal } - ) => Promise<{ - data: ValidateControlDataResponse; - error?: unknown; - response?: Response; - }> - )('/api/v1/controls/validate', { body: { data }, signal }), + apiClient.POST('/api/v1/controls/validate', { + body: { data }, + signal, + }) as Promise<{ + data?: ValidateControlDataResponse; + error?: unknown; + response: Response; + }>, delete: (controlId: number, options?: { force?: boolean }) => apiClient.DELETE('/api/v1/controls/{control_id}', { params: { diff --git a/ui/src/core/api/generated/api-types.ts b/ui/src/core/api/generated/api-types.ts index 01625731..e1fe38b4 100644 --- a/ui/src/core/api/generated/api-types.ts +++ b/ui/src/core/api/generated/api-types.ts @@ -465,6 +465,26 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/controls/schema': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get control definition JSON schema + * @description Return the canonical JSON schema for ControlDefinition. + */ + get: operations['get_control_schema_api_v1_controls_schema_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/controls/validate': { parameters: { query?: never; @@ -2584,6 +2604,16 @@ export interface components { */ name: string; }; + /** GetControlSchemaResponse */ + GetControlSchemaResponse: { + /** + * Schema + * @description JSON Schema for a full ControlDefinition payload + */ + schema: { + [key: string]: unknown; + }; + }; /** * GetPolicyControlsResponse * @description Response containing control IDs associated with a policy. @@ -3337,6 +3367,10 @@ export interface components { }; /** ValidationError */ ValidationError: { + /** Context */ + ctx?: Record; + /** Input */ + input?: unknown; /** Location */ loc: (string | number)[]; /** Message */ @@ -4015,6 +4049,26 @@ export interface operations { }; }; }; + get_control_schema_api_v1_controls_schema_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description JSON schema for ControlDefinition */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['GetControlSchemaResponse']; + }; + }; + }; + }; validate_control_data_api_v1_controls_validate_post: { parameters: { query?: never; diff --git a/ui/src/core/api/types.ts b/ui/src/core/api/types.ts index a4720f5f..78da03e6 100644 --- a/ui/src/core/api/types.ts +++ b/ui/src/core/api/types.ts @@ -104,6 +104,8 @@ export type SetControlDataResponse = components['schemas']['SetControlDataResponse']; export type GetControlDataResponse = components['schemas']['GetControlDataResponse']; +export type GetControlSchemaResponse = + components['schemas']['GetControlSchemaResponse']; export type ValidateControlDataRequest = components['schemas']['ValidateControlDataRequest']; diff --git a/ui/src/core/hooks/query-hooks/use-control-schema.ts b/ui/src/core/hooks/query-hooks/use-control-schema.ts new file mode 100644 index 00000000..75a01b6b --- /dev/null +++ b/ui/src/core/hooks/query-hooks/use-control-schema.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/core/api/client'; +import type { GetControlSchemaResponse } from '@/core/api/types'; + +export function useControlSchema() { + return useQuery({ + queryKey: ['controls', 'schema'], + queryFn: async () => { + const { data, error } = await api.controls.getSchema(); + if (error) throw error; + return data!; + }, + staleTime: Infinity, + }); +} diff --git a/ui/src/core/page-components/agent-detail/agent-detail.tsx b/ui/src/core/page-components/agent-detail/agent-detail.tsx index 497d4df8..4c35c4ce 100644 --- a/ui/src/core/page-components/agent-detail/agent-detail.tsx +++ b/ui/src/core/page-components/agent-detail/agent-detail.tsx @@ -372,6 +372,7 @@ const AgentDetailPage = ({ agentId, defaultTab }: AgentDetailPageProps) => { onClose={handleCloseEditModal} title="Edit Control" size="xl" + closeOnEscape={false} styles={{ title: { fontSize: '18px', fontWeight: 600 }, content: { maxWidth: '1500px', width: '95vw' }, diff --git a/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx b/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx index 69a029d6..483c586b 100644 --- a/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx +++ b/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx @@ -232,6 +232,7 @@ export function AddNewControlModal({ size="xxl" padding={0} withCloseButton={false} + closeOnEscape={false} styles={{ body: { padding: 0, @@ -283,7 +284,7 @@ export function AddNewControlModal({ data-testid="from-json-button" onClick={handleFromJsonClick} > - From JSON + Write your own Learn here on how to add new type of evaluator.{' '} @@ -342,6 +343,7 @@ export function AddNewControlModal({ title="Create Control" size="xl" keepMounted={false} + closeOnEscape={false} styles={{ title: { fontSize: '18px', fontWeight: 600 }, content: { maxWidth: '1500px', width: '90vw' }, diff --git a/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx b/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx index a22a93e2..d968e692 100644 --- a/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx +++ b/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx @@ -324,6 +324,7 @@ export function ControlStoreModal({ size="xxl" padding={0} withCloseButton={false} + closeOnEscape={false} styles={{ body: { padding: 0, @@ -441,6 +442,7 @@ export function ControlStoreModal({ title="Create Control" size="xl" keepMounted={false} + closeOnEscape={false} styles={{ title: { fontSize: '18px', fontWeight: 600 }, content: { maxWidth: '1500px', width: '90vw' }, diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx index 9cb71e6e..22fdc92d 100644 --- a/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx +++ b/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx @@ -15,6 +15,8 @@ import { notifications } from '@mantine/notifications'; import { Button } from '@rungalileo/jupiter-ds'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { JsonEditorCodeMirror } from '@/components/json-editor-codemirror'; +import { JsonEditorMonaco } from '@/components/json-editor-monaco'; import { isApiError } from '@/core/api/errors'; import type { Control, @@ -23,6 +25,8 @@ import type { } from '@/core/api/types'; import { useAddControlToAgent } from '@/core/hooks/query-hooks/use-add-control-to-agent'; import { useAgent } from '@/core/hooks/query-hooks/use-agent'; +import { useControlSchema } from '@/core/hooks/query-hooks/use-control-schema'; +import { useEvaluators } from '@/core/hooks/query-hooks/use-evaluators'; import { useUpdateControl } from '@/core/hooks/query-hooks/use-update-control'; import { useUpdateControlMetadata } from '@/core/hooks/query-hooks/use-update-control-metadata'; import { useValidateControlData } from '@/core/hooks/query-hooks/use-validate-control-data'; @@ -35,11 +39,11 @@ import { } from './control-condition'; import { ControlDefinitionForm } from './control-definition-form'; import { EvaluatorConfigSection } from './evaluator-config-section'; -import { JsonEditorView } from './json-editor-view'; import type { ControlDefinitionFormValues, ControlEditorMode, EditControlMode, + JsonEditorEvaluatorOption, } from './types'; import { useEvaluatorConfigState } from './use-evaluator-config-state'; import { applyApiErrorsToForms } from './utils'; @@ -47,6 +51,31 @@ import { applyApiErrorsToForms } from './utils'; const EVALUATOR_CONFIG_HEIGHT = 450; const JSON_EDITOR_HEIGHT = 520; type ValidationStatus = 'idle' | 'validating' | 'valid' | 'invalid'; +type JsonEditorEngine = 'monaco' | 'codemirror'; +const JSON_EDITOR_ENGINE_STORAGE_KEY = 'editControl.jsonEditorEngine'; + +const DEFAULT_CONTROL_TEMPLATE = JSON.stringify( + { + description: 'Block outputs containing US Social Security Numbers', + enabled: true, + execution: 'server', + scope: { + step_types: ['llm'], + stages: ['post'], + }, + condition: { + selector: { path: 'output' }, + evaluator: { + name: 'regex', + config: { pattern: '\\b\\d{3}-\\d{2}-\\d{4}\\b' }, + }, + }, + action: { decision: 'deny' }, + tags: ['pii', 'compliance'], + }, + null, + 2 +); export type EditControlContentProps = { /** The control to edit/create template */ @@ -72,7 +101,10 @@ export const EditControlContent = ({ initialEditorMode = 'form', }: EditControlContentProps) => { const { data: agentResponse } = useAgent(agentId); + const { data: controlSchemaResponse } = useControlSchema(); + const { data: globalEvaluators } = useEvaluators(); const steps = agentResponse?.steps ?? []; + const agentName = agentResponse?.agent?.agent_name ?? agentId; const [workingDefinition, setWorkingDefinition] = useState( control.control @@ -91,6 +123,8 @@ export const EditControlContent = ({ useState(null); const [definitionValidationStatus, setDefinitionValidationStatus] = useState('idle'); + const [jsonEditorEngine, setJsonEditorEngine] = + useState('monaco'); const updateControl = useUpdateControl(); const updateControlMetadata = useUpdateControlMetadata(); @@ -107,6 +141,38 @@ export const EditControlContent = ({ () => getControlConditionState(workingDefinition), [workingDefinition] ); + const availableEvaluators = useMemo(() => { + const merged = new Map(); + + for (const [id, evaluatorInfo] of Object.entries(globalEvaluators ?? {})) { + merged.set(id, { + id, + label: evaluatorInfo.name, + description: evaluatorInfo.description, + source: 'global', + configSchema: evaluatorInfo.config_schema, + }); + } + + for (const evaluatorSchema of agentResponse?.evaluators ?? []) { + const id = `${agentName}:${evaluatorSchema.name}`; + merged.set(id, { + id, + label: evaluatorSchema.name, + description: evaluatorSchema.description, + source: 'agent', + configSchema: evaluatorSchema.config_schema, + }); + } + + return [...merged.values()]; + }, [agentName, agentResponse?.evaluators, globalEvaluators]); + const activeEvaluatorOption = useMemo( + () => + availableEvaluators.find((candidate) => candidate.id === evaluatorId) ?? + null, + [availableEvaluators, evaluatorId] + ); const definitionForm = useForm({ initialValues: { @@ -394,7 +460,9 @@ export const EditControlContent = ({ setEditorMode(initialEditorMode); setDefinitionJsonText( initialEditorMode === 'json' - ? JSON.stringify(control.control, null, 2) + ? mode === 'create' + ? DEFAULT_CONTROL_TEMPLATE + : JSON.stringify(control.control, null, 2) : '' ); setDefinitionJsonError(null); @@ -402,6 +470,22 @@ export const EditControlContent = ({ setDefinitionValidationStatus('idle'); }, [control.control, initialEditorMode]); + useEffect(() => { + if (typeof window === 'undefined') return; + const stored = window.localStorage.getItem(JSON_EDITOR_ENGINE_STORAGE_KEY); + if (stored === 'monaco' || stored === 'codemirror') { + setJsonEditorEngine(stored); + } + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem( + JSON_EDITOR_ENGINE_STORAGE_KEY, + jsonEditorEngine + ); + }, [jsonEditorEngine]); + useEffect(() => { reset(); setApiError(null); @@ -707,6 +791,19 @@ export const EditControlContent = ({ ]} size="xs" /> + {editorMode === 'json' ? ( + + setJsonEditorEngine(value as JsonEditorEngine) + } + data={[ + { value: 'monaco', label: 'Monaco' }, + { value: 'codemirror', label: 'CodeMirror' }, + ]} + size="xs" + /> + ) : null} @@ -727,21 +824,47 @@ export const EditControlContent = ({ {editorMode === 'json' ? ( - + {jsonEditorEngine === 'monaco' ? ( + + ) : ( + + )} ) : ( @@ -762,6 +885,8 @@ export const EditControlContent = ({ height={EVALUATOR_CONFIG_HEIGHT} onConfigChange={syncJsonToForm} onValidateConfig={validateEvaluatorConfig} + activeEvaluatorId={evaluatorId} + activeEvaluatorSchema={activeEvaluatorOption?.configSchema} /> ) : ( diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/evaluator-config-section.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/evaluator-config-section.tsx index 4b0a1f40..b3a42731 100644 --- a/ui/src/core/page-components/agent-detail/modals/edit-control/evaluator-config-section.tsx +++ b/ui/src/core/page-components/agent-detail/modals/edit-control/evaluator-config-section.tsx @@ -15,6 +15,7 @@ import type { ProblemDetail } from '@/core/api/types'; import { JsonEditorView } from './json-editor-view'; import type { ConfigViewMode } from './types'; +import type { JsonSchema } from './types'; const DEFAULT_HEIGHT = 450; const CONTENT_MIN_HEIGHT_EXTRA = 60; @@ -39,6 +40,8 @@ type EvaluatorConfigSectionProps = { evaluatorForm: UseFormReturnType; formComponent?: React.ComponentType<{ form: UseFormReturnType }>; height?: number; + activeEvaluatorId?: string; + activeEvaluatorSchema?: JsonSchema | null; }; export function EvaluatorConfigSection({ @@ -47,6 +50,8 @@ export function EvaluatorConfigSection({ evaluatorForm, formComponent: FormComponent, height = DEFAULT_HEIGHT, + activeEvaluatorId, + activeEvaluatorSchema, }: EvaluatorConfigSectionProps) { const [validationStatus, setValidationStatus] = useState('idle'); @@ -131,6 +136,9 @@ export function EvaluatorConfigSection({ label="Configuration (JSON)" tooltip="Raw evaluator configuration in JSON format" testId="raw-json-textarea" + editorMode="evaluator-config" + activeEvaluatorId={activeEvaluatorId} + schema={activeEvaluatorSchema} {...jsonViewProps} /> )} diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/json-editor-language.ts b/ui/src/core/page-components/agent-detail/modals/edit-control/json-editor-language.ts new file mode 100644 index 00000000..7a501779 --- /dev/null +++ b/ui/src/core/page-components/agent-detail/modals/edit-control/json-editor-language.ts @@ -0,0 +1,1941 @@ +import { + findNodeAtLocation, + findNodeAtOffset, + getLocation, + type Node as JsonNode, + type ParseError, + parseTree, +} from 'jsonc-parser'; + +import type { StepSchema } from '@/core/api/types'; + +import type { + JsonEditorEvaluatorOption, + JsonEditorMode, + JsonSchema, +} from './types'; + +type MonacoModule = typeof import('monaco-editor'); +type JsonPath = Array; + +type JsonEditorAutocompleteContext = { + mode: JsonEditorMode; + modelUri: string; + schema?: JsonSchema | null; + evaluators?: JsonEditorEvaluatorOption[]; + activeEvaluatorId?: string | null; + steps?: StepSchema[]; +}; + +type SelectorPathSuggestion = { + label: string; + detail: string; + rank: number; +}; + +type SchemaCursor = { + schema: JsonSchema | null; + rootSchema: JsonSchema | null; +}; + +type SnippetState = { + nextTabStop: number; +}; + +const ROOT_SELECTOR_PATHS = ['*', 'input', 'output', 'context', 'name', 'type']; +const COMPLETION_TRIGGER_CHARACTERS = ['"', ':', '.', ',', '[']; +const SCHEMA_COMPOSITION_KEYS = ['$ref', 'allOf', 'anyOf', 'oneOf']; +const RESERVED_SCHEMA_KEYS = new Set([ + ...SCHEMA_COMPOSITION_KEYS, + '$defs', + 'additionalProperties', + 'default', + 'description', + 'enum', + 'examples', + 'items', + 'properties', + 'required', + 'title', + 'type', +]); + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function asSchema(schema: unknown): JsonSchema | null { + return isObject(schema) ? schema : null; +} + +function getStringArrayAtPath( + tree: JsonNode | undefined, + path: JsonPath +): string[] { + const node = tree ? findNodeAtLocation(tree, path) : undefined; + if (!node || node.type !== 'array' || !node.children) { + return []; + } + + return node.children + .map((child) => (typeof child.value === 'string' ? child.value : null)) + .filter((value): value is string => value !== null); +} + +function getScopeFilters(tree: JsonNode | undefined): { + stepTypes: string[]; + stepNames: string[]; +} { + return { + stepTypes: getStringArrayAtPath(tree, ['scope', 'step_types']), + stepNames: getStringArrayAtPath(tree, ['scope', 'step_names']), + }; +} + +function getJsonPathFieldIndex(path: JsonPath, fieldName: string): number { + for (let index = path.length - 1; index >= 0; index -= 1) { + if (path[index] === fieldName) { + return index; + } + } + return -1; +} + +function getRangeForNodeContent( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + node: JsonNode | undefined +) { + if (!node || node.type !== 'string') { + return null; + } + + const start = model.getPositionAt(node.offset + 1); + const end = model.getPositionAt(node.offset + Math.max(node.length - 1, 1)); + + return new monaco.Range( + start.lineNumber, + start.column, + end.lineNumber, + end.column + ); +} + +function getDefaultRange( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position +) { + const word = model.getWordUntilPosition(position); + + return new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ); +} + +function getReplaceRange( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position, + node: JsonNode | undefined +) { + return ( + getRangeForNodeContent(monaco, model, node) ?? + getDefaultRange(monaco, model, position) + ); +} + +function getPropertyKeyReplaceRange( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + node: JsonNode | undefined +) { + if (!node || node.type !== 'string') { + return null; + } + + const start = model.getPositionAt(node.offset + 1); + const end = model.getPositionAt(node.offset + node.length); + + return new monaco.Range( + start.lineNumber, + start.column, + end.lineNumber, + end.column + ); +} + +function unescapeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function resolveJsonPointer( + rootSchema: JsonSchema | null, + ref: string +): JsonSchema | null { + if (!rootSchema || !ref.startsWith('#/')) { + return null; + } + + let current: unknown = rootSchema; + for (const segment of ref + .slice(2) + .split('/') + .map(unescapeJsonPointerSegment)) { + if (!isObject(current) || !(segment in current)) { + return null; + } + current = current[segment]; + } + + return asSchema(current); +} + +function getSchemaTypes(schema: unknown): string[] { + if (!isObject(schema)) { + return []; + } + + if (typeof schema.type === 'string') { + return [schema.type]; + } + + if (!Array.isArray(schema.type)) { + return []; + } + + return schema.type.filter( + (value): value is string => typeof value === 'string' + ); +} + +function getSchemaType(schema: unknown): string | null { + return getSchemaTypes(schema).find((value) => value !== 'null') ?? null; +} + +function getSchemaEnumValues(schema: unknown): unknown[] { + return isObject(schema) && Array.isArray(schema.enum) ? schema.enum : []; +} + +function stripCompositionKeys(schema: JsonSchema): JsonSchema { + const stripped = { ...schema }; + for (const key of SCHEMA_COMPOSITION_KEYS) { + delete stripped[key]; + } + return stripped; +} + +function mergeSchemas( + schemas: JsonSchema[], + baseSchema?: JsonSchema | null +): JsonSchema { + const merged: JsonSchema = baseSchema ? stripCompositionKeys(baseSchema) : {}; + const properties: Record = {}; + const required = new Set(); + const enumValues: unknown[] = []; + const types = new Set(); + let items: unknown; + let additionalProperties: unknown; + + for (const schema of schemas) { + for (const type of getSchemaTypes(schema)) { + if (type !== 'null') { + types.add(type); + } + } + + for (const value of getSchemaEnumValues(schema)) { + if (!enumValues.some((candidate) => candidate === value)) { + enumValues.push(value); + } + } + + if (isObject(schema.properties)) { + Object.assign(properties, schema.properties); + } + + if (Array.isArray(schema.required)) { + for (const value of schema.required) { + if (typeof value === 'string') { + required.add(value); + } + } + } + + if (items === undefined && schema.items !== undefined) { + items = schema.items; + } + + if ( + additionalProperties === undefined && + schema.additionalProperties !== undefined + ) { + additionalProperties = schema.additionalProperties; + } + + if ( + merged.description === undefined && + typeof schema.description === 'string' + ) { + merged.description = schema.description; + } + + if (merged.title === undefined && typeof schema.title === 'string') { + merged.title = schema.title; + } + + if (merged.default === undefined && 'default' in schema) { + merged.default = schema.default; + } + + if ( + merged.examples === undefined && + Array.isArray(schema.examples) && + schema.examples.length > 0 + ) { + merged.examples = schema.examples; + } + } + + if (Object.keys(properties).length > 0) { + merged.properties = properties; + } + + if (required.size > 0) { + merged.required = [...required]; + } + + if (enumValues.length > 0) { + merged.enum = enumValues; + } + + if (types.size === 1) { + merged.type = [...types][0]; + } else if (types.size > 1) { + merged.type = [...types]; + } + + if (items !== undefined) { + merged.items = items; + } + + if (additionalProperties !== undefined) { + merged.additionalProperties = additionalProperties; + } + + return merged; +} + +function normalizeSchema( + schema: unknown, + rootSchema: JsonSchema | null, + seenRefs: Set = new Set() +): JsonSchema | null { + const current = asSchema(schema); + if (!current) { + return null; + } + + if (typeof current.$ref === 'string') { + const ref = current.$ref; + if (seenRefs.has(ref)) { + return stripCompositionKeys(current); + } + + const resolved = resolveJsonPointer(rootSchema, ref); + if (!resolved) { + return stripCompositionKeys(current); + } + + const localOverrides = stripCompositionKeys(current); + const nextSeenRefs = new Set(seenRefs); + nextSeenRefs.add(ref); + const normalizedResolved = normalizeSchema( + resolved, + rootSchema, + nextSeenRefs + ); + return normalizedResolved + ? mergeSchemas([normalizedResolved, localOverrides]) + : localOverrides; + } + + if (Array.isArray(current.allOf) && current.allOf.length > 0) { + const variants = current.allOf + .map((variant) => normalizeSchema(variant, rootSchema, seenRefs)) + .filter((variant): variant is JsonSchema => variant !== null); + + if (variants.length > 0) { + return mergeSchemas(variants, current); + } + } + + const union = Array.isArray(current.anyOf) + ? current.anyOf + : Array.isArray(current.oneOf) + ? current.oneOf + : null; + + if (union && union.length > 0) { + const variants = union + .map((variant) => normalizeSchema(variant, rootSchema, seenRefs)) + .filter((variant): variant is JsonSchema => variant !== null); + + const nonNullVariants = variants.filter( + (variant) => getSchemaType(variant) !== 'null' + ); + + if (nonNullVariants.length > 0) { + return mergeSchemas(nonNullVariants, current); + } + } + + return current; +} + +function getSchemaProperties(schema: unknown): Record { + const normalized = normalizeSchema(schema, asSchema(schema)); + if (!normalized) { + return {}; + } + + if (isObject(normalized.properties)) { + return normalized.properties; + } + + const propertyEntries = Object.entries(normalized).filter( + ([key, value]) => !RESERVED_SCHEMA_KEYS.has(key) && isObject(value) + ); + + return Object.fromEntries(propertyEntries); +} + +function getSchemaRequiredProperties(schema: unknown): string[] { + if (!isObject(schema) || !Array.isArray(schema.required)) { + return []; + } + + return schema.required.filter( + (value): value is string => typeof value === 'string' + ); +} + +function getSchemaAtProperty( + schema: JsonSchema | null, + propertyName: string, + rootSchema: JsonSchema | null +): JsonSchema | null { + const normalized = normalizeSchema(schema, rootSchema); + if (!normalized) { + return null; + } + + const properties = getSchemaProperties(normalized); + if (propertyName in properties) { + return normalizeSchema(properties[propertyName], rootSchema); + } + + if (isObject(normalized.additionalProperties)) { + return normalizeSchema(normalized.additionalProperties, rootSchema); + } + + return null; +} + +function getArrayItemSchema( + schema: JsonSchema | null, + rootSchema: JsonSchema | null +): JsonSchema | null { + const normalized = normalizeSchema(schema, rootSchema); + if (!normalized) { + return null; + } + + return normalizeSchema(normalized.items, rootSchema); +} + +function getPropertyKeyContext( + path: JsonPath, + isAtPropertyKey: boolean +): { objectPath: JsonPath; replaceExistingKey: boolean } | null { + if (!isAtPropertyKey || path.length === 0) { + return null; + } + + const last = path[path.length - 1]; + if (last === '') { + return { objectPath: path.slice(0, -1), replaceExistingKey: false }; + } + + if (typeof last === 'string') { + return { objectPath: path.slice(0, -1), replaceExistingKey: true }; + } + + return null; +} + +function isSelectorPathLocation(path: JsonPath): boolean { + return ( + path.length >= 2 && + path[path.length - 1] === 'path' && + path[path.length - 2] === 'selector' + ); +} + +function isEvaluatorNameLocation(path: JsonPath): boolean { + return ( + path.length >= 2 && + path[path.length - 1] === 'name' && + path[path.length - 2] === 'evaluator' + ); +} + +function escapeSnippetValue(value: string): string { + return value.replace(/[\\$}]/g, '\\$&'); +} + +function toJsonLiteral(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function getSchemaDescription(schema: unknown): string | undefined { + return isObject(schema) && typeof schema.description === 'string' + ? schema.description + : undefined; +} + +function getSchemaTitle(schema: unknown): string | undefined { + return isObject(schema) && typeof schema.title === 'string' + ? schema.title + : undefined; +} + +function isSchemaWithProperties( + schema: JsonSchema, + propertyNames: string[] +): boolean { + const properties = getSchemaProperties(schema); + return propertyNames.every((propertyName) => propertyName in properties); +} + +function getSchemaExamples(schema: unknown): unknown[] { + return isObject(schema) && Array.isArray(schema.examples) + ? schema.examples + : []; +} + +function getSchemaDefault(schema: unknown): unknown { + return isObject(schema) && 'default' in schema ? schema.default : undefined; +} + +function nextSnippetTabStop( + snippetState: SnippetState, + defaultValue?: string +): string { + const tabStop = snippetState.nextTabStop; + snippetState.nextTabStop += 1; + + if (defaultValue) { + return `\${${tabStop}:${escapeSnippetValue(defaultValue)}}`; + } + + return `\${${tabStop}}`; +} + +function getSuggestedObjectPropertyNames(schema: JsonSchema): string[] { + const properties = Object.keys(getSchemaProperties(schema)); + if (properties.length === 0) { + return []; + } + + const required = getSchemaRequiredProperties(schema); + if (required.length > 0) { + return required.filter((propertyName) => properties.includes(propertyName)); + } + + if (properties.length === 1) { + return properties; + } + + return []; +} + +function buildSchemaValueSnippet( + schema: JsonSchema | null, + rootSchema: JsonSchema | null, + snippetState: SnippetState, + depth = 0 +): string { + const normalized = normalizeSchema(schema, rootSchema); + if (!normalized || depth > 4) { + return nextSnippetTabStop(snippetState); + } + + const enumValues = getSchemaEnumValues(normalized); + if (enumValues.length > 0) { + return toJsonLiteral(enumValues[0]); + } + + const examples = getSchemaExamples(normalized); + const defaultValue = getSchemaDefault(normalized); + const preferredValue = + defaultValue !== undefined ? defaultValue : examples[0]; + const schemaTitle = getSchemaTitle(normalized); + + if ( + schemaTitle === 'ControlSelector' || + isSchemaWithProperties(normalized, ['path']) + ) { + return '{\n "path": "*"\n}'; + } + + if ( + schemaTitle === 'EvaluatorSpec' || + isSchemaWithProperties(normalized, ['name', 'config']) + ) { + return '{\n "name": "",\n "config": {}\n}'; + } + + if ( + schemaTitle === 'ControlAction' || + isSchemaWithProperties(normalized, ['decision', 'steering_context']) + ) { + return '{\n "decision": "deny"\n}'; + } + + if ( + schemaTitle === 'ControlScope' || + isSchemaWithProperties(normalized, ['step_types', 'stages']) + ) { + return '{\n "step_types": ["llm"],\n "stages": ["post"]\n}'; + } + + if ( + schemaTitle === 'ConditionNode' || + isSchemaWithProperties(normalized, [ + 'selector', + 'evaluator', + 'and', + 'or', + 'not', + ]) + ) { + return '{}'; + } + + switch (getSchemaType(normalized)) { + case 'object': { + return '{}'; + } + case 'array': { + return '[]'; + } + case 'boolean': { + return String( + typeof preferredValue === 'boolean' ? preferredValue : true + ); + } + case 'integer': + case 'number': { + return String(typeof preferredValue === 'number' ? preferredValue : 0); + } + case 'string': { + if (typeof preferredValue === 'string' && preferredValue.length > 0) { + return `"${escapeSnippetValue(preferredValue)}"`; + } + return '""'; + } + default: { + if (preferredValue !== undefined) { + return toJsonLiteral(preferredValue); + } + return 'null'; + } + } +} + +function buildPropertyInsertText( + propertyName: string, + propertySchema: JsonSchema | null, + rootSchema: JsonSchema | null, + replaceExistingKey = false +): string { + const snippetState: SnippetState = { nextTabStop: 1 }; + const valueSnippet = buildSchemaValueSnippet( + propertySchema, + rootSchema, + snippetState + ); + const prefix = replaceExistingKey + ? `${escapeSnippetValue(propertyName)}": ` + : `"${escapeSnippetValue(propertyName)}": `; + return `${prefix}${valueSnippet}`; +} + +function buildValueInsertText( + value: unknown, + isStringValueContext: boolean +): string { + return typeof value === 'string' && isStringValueContext + ? value + : toJsonLiteral(value); +} + +function getObjectPropertyNames(node: JsonNode | undefined): Set { + if (!node || node.type !== 'object' || !node.children) { + return new Set(); + } + + return new Set( + node.children + .map((propertyNode) => { + const keyNode = propertyNode.children?.[0]; + return typeof keyNode?.value === 'string' ? keyNode.value : null; + }) + .filter((value): value is string => value !== null) + ); +} + +function getExistingKeysFromText(text: string, offset: number): Set { + let braceDepth = 0; + let objectStart = -1; + for (let i = offset - 1; i >= 0; i -= 1) { + if (text[i] === '}') braceDepth += 1; + if (text[i] === '{') { + if (braceDepth === 0) { + objectStart = i; + break; + } + braceDepth -= 1; + } + } + if (objectStart < 0) return new Set(); + + braceDepth = 0; + let objectEnd = text.length; + for (let i = objectStart; i < text.length; i += 1) { + if (text[i] === '{') braceDepth += 1; + if (text[i] === '}') { + braceDepth -= 1; + if (braceDepth === 0) { + objectEnd = i; + break; + } + } + } + + const keys = new Set(); + const pattern = /"([^"]+)"\s*:/g; + let match; + const slice = text.substring(objectStart, objectEnd + 1); + while ((match = pattern.exec(slice)) !== null) { + keys.add(match[1]); + } + return keys; +} + +function walkSchemaPaths( + schema: unknown, + basePath: string, + output: Set, + depth = 0 +) { + if (depth > 5) { + return; + } + + output.add(basePath); + const properties = getSchemaProperties(schema); + for (const [propertyName, propertySchema] of Object.entries(properties)) { + const childPath = `${basePath}.${propertyName}`; + output.add(childPath); + walkSchemaPaths(propertySchema, childPath, output, depth + 1); + } +} + +function buildSelectorPathSuggestions( + steps: StepSchema[] | undefined, + tree: JsonNode | undefined +): SelectorPathSuggestion[] { + const suggestions = new Map(); + const { stepTypes, stepNames } = getScopeFilters(tree); + const rankedSteps = steps ?? []; + + for (const rootPath of ROOT_SELECTOR_PATHS) { + suggestions.set(rootPath, { + label: rootPath, + detail: 'Built-in control selector root', + rank: 0, + }); + } + + const getStepRank = (step: StepSchema): number => { + const typeMatches = stepTypes.length === 0 || stepTypes.includes(step.type); + const nameMatches = stepNames.length === 0 || stepNames.includes(step.name); + + if (typeMatches && nameMatches) return 0; + if (typeMatches || nameMatches) return 1; + return 2; + }; + + for (const step of rankedSteps) { + const rank = getStepRank(step); + const stepLabel = `${step.type}:${step.name}`; + const inputPaths = new Set(['input']); + const outputPaths = new Set(['output']); + + if (step.input_schema) { + walkSchemaPaths(step.input_schema, 'input', inputPaths); + } + + if (step.output_schema) { + walkSchemaPaths(step.output_schema, 'output', outputPaths); + } + + for (const path of [...inputPaths, ...outputPaths]) { + const existing = suggestions.get(path); + if (!existing || rank < existing.rank) { + suggestions.set(path, { + label: path, + detail: stepLabel, + rank, + }); + } + } + } + + return [...suggestions.values()].sort((left, right) => { + if (left.rank !== right.rank) { + return left.rank - right.rank; + } + return left.label.localeCompare(right.label); + }); +} + +function findEvaluatorById( + evaluators: JsonEditorEvaluatorOption[] | undefined, + id: string | null | undefined +): JsonEditorEvaluatorOption | null { + if (!evaluators || !id) { + return null; + } + + return evaluators.find((candidate) => candidate.id === id) ?? null; +} + +function resolveActiveEvaluator( + context: JsonEditorAutocompleteContext, + tree: JsonNode | undefined, + path: JsonPath +): JsonEditorEvaluatorOption | null { + if (context.mode === 'evaluator-config') { + return findEvaluatorById(context.evaluators, context.activeEvaluatorId); + } + + const evaluatorIndex = getJsonPathFieldIndex(path, 'evaluator'); + if (!tree || evaluatorIndex < 0) { + return null; + } + + const evaluatorNamePath = [ + ...path.slice(0, evaluatorIndex), + 'evaluator', + 'name', + ]; + const evaluatorNameNode = findNodeAtLocation(tree, evaluatorNamePath); + const evaluatorName = + typeof evaluatorNameNode?.value === 'string' + ? evaluatorNameNode.value + : null; + + return findEvaluatorById(context.evaluators, evaluatorName); +} + +function getInitialSchemaCursor( + context: JsonEditorAutocompleteContext, + activeEvaluator: JsonEditorEvaluatorOption | null +): SchemaCursor { + if (context.mode === 'evaluator-config') { + const rootSchema = asSchema(activeEvaluator?.configSchema ?? null); + return { + schema: normalizeSchema(rootSchema, rootSchema), + rootSchema, + }; + } + + const rootSchema = asSchema(context.schema ?? null); + return { + schema: normalizeSchema(rootSchema, rootSchema), + rootSchema, + }; +} + +function isEvaluatorConfigSegment(path: JsonPath, index: number): boolean { + return ( + typeof path[index] === 'string' && + path[index] === 'config' && + index > 0 && + path[index - 1] === 'evaluator' + ); +} + +function resolveSchemaAtJsonPath( + context: JsonEditorAutocompleteContext, + activeEvaluator: JsonEditorEvaluatorOption | null, + path: JsonPath +): SchemaCursor { + let cursor = getInitialSchemaCursor(context, activeEvaluator); + + for (let index = 0; index < path.length; index += 1) { + const segment = path[index]; + if (!cursor.schema) { + return cursor; + } + + if (context.mode === 'control' && isEvaluatorConfigSegment(path, index)) { + const rootSchema = asSchema(activeEvaluator?.configSchema ?? null); + cursor = { + schema: normalizeSchema(rootSchema, rootSchema), + rootSchema, + }; + continue; + } + + if (typeof segment === 'number') { + cursor = { + schema: getArrayItemSchema(cursor.schema, cursor.rootSchema), + rootSchema: cursor.rootSchema, + }; + continue; + } + + cursor = { + schema: getSchemaAtProperty(cursor.schema, segment, cursor.rootSchema), + rootSchema: cursor.rootSchema, + }; + } + + return cursor; +} + +function buildEvaluatorNameSuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + evaluators: JsonEditorEvaluatorOption[] | undefined, + isStringValueContext: boolean +) { + return (evaluators ?? []).map((evaluator, index) => ({ + label: evaluator.id, + kind: monaco.languages.CompletionItemKind.Value, + detail: + evaluator.source === 'agent' + ? `${evaluator.label} (agent evaluator)` + : evaluator.label, + documentation: evaluator.description ?? undefined, + insertText: buildValueInsertText(evaluator.id, isStringValueContext), + range, + sortText: `!0${index.toString().padStart(3, '0')}`, + })); +} + +function buildSelectorSuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + steps: StepSchema[] | undefined, + tree: JsonNode | undefined, + isStringValueContext: boolean +) { + return buildSelectorPathSuggestions(steps, tree).map((suggestion, index) => ({ + label: suggestion.label, + kind: monaco.languages.CompletionItemKind.Value, + detail: suggestion.detail, + insertText: buildValueInsertText(suggestion.label, isStringValueContext), + range, + sortText: `!${suggestion.rank}${index.toString().padStart(3, '0')}`, + })); +} + +function buildSchemaPropertySuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + schemaCursor: SchemaCursor, + tree: JsonNode | undefined, + objectPath: JsonPath, + replaceExistingKey: boolean, + currentPropertyName: string | null, + text: string, + offset: number +) { + if (!schemaCursor.schema) { + return []; + } + + const objectNode = tree ? findNodeAtLocation(tree, objectPath) : undefined; + // Use AST-based key detection, with text-based fallback for broken JSON + const existingKeys = objectNode + ? getObjectPropertyNames(objectNode) + : getExistingKeysFromText(text, offset); + if (currentPropertyName) { + existingKeys.delete(currentPropertyName); + } + + return Object.entries(getSchemaProperties(schemaCursor.schema)) + .filter( + ([propertyName]) => + !existingKeys.has(propertyName) && !propertyName.startsWith('$') + ) + .map(([propertyName, propertySchema], index) => ({ + label: propertyName, + kind: monaco.languages.CompletionItemKind.Property, + detail: getSchemaDescription(propertySchema), + documentation: getSchemaDescription(propertySchema), + insertText: buildPropertyInsertText( + propertyName, + asSchema(propertySchema), + schemaCursor.rootSchema, + replaceExistingKey + ), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: `!1${index.toString().padStart(3, '0')}`, + })); +} + +function buildSchemaValueSuggestions( + monaco: MonacoModule, + range: import('monaco-editor').IRange, + schemaCursor: SchemaCursor, + isStringValueContext: boolean +) { + const schema = schemaCursor.schema; + if (!schema) { + return []; + } + + const suggestions: import('monaco-editor').languages.CompletionItem[] = []; + const enumValues = getSchemaEnumValues(schema); + + if (enumValues.length > 0) { + suggestions.push( + ...enumValues.map((value, index) => ({ + label: String(value), + kind: monaco.languages.CompletionItemKind.Value, + detail: getSchemaTitle(schema) ?? getSchemaDescription(schema), + insertText: buildValueInsertText(value, isStringValueContext), + range, + sortText: `!2${index.toString().padStart(3, '0')}`, + })) + ); + return suggestions; + } + + const schemaType = getSchemaType(schema); + if (schemaType === 'boolean') { + suggestions.push( + ...['true', 'false'].map((value, index) => ({ + label: value, + kind: monaco.languages.CompletionItemKind.Value, + detail: getSchemaTitle(schema) ?? getSchemaDescription(schema), + insertText: value, + range, + sortText: `!2${index.toString().padStart(3, '0')}`, + })) + ); + return suggestions; + } + + const preferredValues = [ + getSchemaDefault(schema), + ...getSchemaExamples(schema), + ].filter((value, index, collection) => { + if (value === undefined || value === null) { + return false; + } + + return collection.findIndex((candidate) => candidate === value) === index; + }); + + for (const [index, value] of preferredValues.entries()) { + suggestions.push({ + label: typeof value === 'string' ? value : toJsonLiteral(value), + kind: monaco.languages.CompletionItemKind.Value, + detail: 'Schema example', + insertText: buildValueInsertText(value, isStringValueContext), + range, + sortText: `!3${index.toString().padStart(3, '0')}`, + }); + } + + if (schemaType === 'object' || schemaType === 'array') { + const snippetState: SnippetState = { nextTabStop: 1 }; + suggestions.push({ + label: schemaType === 'object' ? 'object' : 'array', + kind: monaco.languages.CompletionItemKind.Snippet, + detail: + schemaType === 'object' + ? 'Insert an object matching the schema' + : 'Insert an array matching the schema', + documentation: getSchemaDescription(schema), + insertText: buildSchemaValueSnippet( + schema, + schemaCursor.rootSchema, + snippetState + ), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + sortText: '!4schema', + }); + } + + return suggestions; +} + +function getCompletionLabel( + item: import('monaco-editor').languages.CompletionItem +): string { + return typeof item.label === 'string' ? item.label : item.label.label; +} + +function dedupeSuggestions( + suggestions: import('monaco-editor').languages.CompletionItem[] +) { + const seen = new Set(); + + return suggestions.filter((item) => { + const key = `${getCompletionLabel(item)}::${String(item.insertText ?? '')}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function buildCompletionSuggestions( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position, + context: JsonEditorAutocompleteContext +): import('monaco-editor').languages.CompletionItem[] { + const text = model.getValue(); + const offset = model.getOffsetAt(position); + const tree = parseTree(text); + const location = getLocation(text, offset); + const node = + tree && offset > 0 ? findNodeAtOffset(tree, offset - 1, true) : tree; + const valueRange = getReplaceRange(monaco, model, position, node); + const isStringValueContext = + node?.type === 'string' && !location.isAtPropertyKey; + const suggestions: import('monaco-editor').languages.CompletionItem[] = []; + + const activeEvaluator = resolveActiveEvaluator(context, tree, location.path); + + if (isEvaluatorNameLocation(location.path)) { + suggestions.push( + ...buildEvaluatorNameSuggestions( + monaco, + valueRange, + context.evaluators, + isStringValueContext + ) + ); + } + + if (isSelectorPathLocation(location.path)) { + suggestions.push( + ...buildSelectorSuggestions( + monaco, + valueRange, + context.steps, + tree, + isStringValueContext + ) + ); + } + + const propertyKeyContext = getPropertyKeyContext( + location.path, + location.isAtPropertyKey + ); + + if (propertyKeyContext) { + // Only treat as replacing when cursor is inside a quoted string node. + // For bare text (typing without "), we need the leading " in the insert. + const hasStringNode = node?.type === 'string'; + const replaceExistingKey = hasStringNode; + + const propertyRange = + (hasStringNode + ? getPropertyKeyReplaceRange(monaco, model, node) + : null) ?? getDefaultRange(monaco, model, position); + const schemaCursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + propertyKeyContext.objectPath + ); + const currentPropertyName = + replaceExistingKey && typeof node?.value === 'string' ? node.value : null; + + suggestions.push( + ...buildSchemaPropertySuggestions( + monaco, + propertyRange, + schemaCursor, + tree, + propertyKeyContext.objectPath, + replaceExistingKey, + currentPropertyName, + text, + offset + ) + ); + } + + // Only show value suggestions at actual value positions — not on blank lines, + // closing brackets, or property key positions where they're confusing noise. + const lineText = model.getLineContent(position.lineNumber); + const isValuePosition = + !propertyKeyContext && !location.isAtPropertyKey && isStringValueContext; + if (isValuePosition) { + const valueSchemaCursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + location.path + ); + + suggestions.push( + ...buildSchemaValueSuggestions( + monaco, + valueRange, + valueSchemaCursor, + isStringValueContext + ) + ); + } + + return dedupeSuggestions(suggestions); +} + +export function fixJsonCommas(text: string): string { + // 1. Remove trailing commas before } or ] + let fixed = text.replace(/,(\s*[}\]])/g, '$1'); + + // 2. Insert missing commas (detected by jsonc-parser) + const errors: ParseError[] = []; + parseTree(fixed, errors); + + const commaErrors = errors + .filter((e) => e.error === 6 /* CommaExpected */) + .sort((a, b) => b.offset - a.offset); + + for (const error of commaErrors) { + // Insert comma at end of previous value (before whitespace), not at + // the start of the next token where jsonc-parser reports the error. + let insertAt = error.offset; + while (insertAt > 0 && /\s/.test(fixed[insertAt - 1])) { + insertAt -= 1; + } + fixed = fixed.slice(0, insertAt) + ',' + fixed.slice(insertAt); + } + return fixed; +} + +export function getJsonEditorCompletionItems( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + position: import('monaco-editor').Position, + context: JsonEditorAutocompleteContext +) { + return buildCompletionSuggestions(monaco, model, position, context); +} + +type EvaluatorNodeInfo = { + name: string; + nameNode: JsonNode; + configNode: JsonNode | undefined; + evaluatorNode: JsonNode; +}; + +function collectEvaluatorNames( + node: JsonNode | undefined, + result: Map +) { + if (!node || node.type !== 'object' || !node.children) return; + + const evaluatorNode = findNodeAtLocation(node, ['evaluator']); + if (evaluatorNode?.type === 'object') { + const nameNode = findNodeAtLocation(evaluatorNode, ['name']); + const configNode = findNodeAtLocation(evaluatorNode, ['config']); + if (nameNode && typeof nameNode.value === 'string') { + result.set(`${nameNode.offset}`, { + name: nameNode.value, + nameNode, + configNode, + evaluatorNode, + }); + } + } + + for (const key of ['and', 'or'] as const) { + const arrayNode = findNodeAtLocation(node, [key]); + if (arrayNode?.type === 'array' && arrayNode.children) { + for (const child of arrayNode.children) { + collectEvaluatorNames(child, result); + } + } + } + + const notNode = findNodeAtLocation(node, ['not']); + if (notNode?.type === 'object') { + collectEvaluatorNames(notNode, result); + } +} + +export function extractEvaluatorNames(text: string): Map { + const tree = parseTree(text); + if (!tree) return new Map(); + + const conditionNode = findNodeAtLocation(tree, ['condition']); + const result = new Map(); + collectEvaluatorNames(conditionNode, result); + + const names = new Map(); + for (const [key, info] of result) { + names.set(key, info.name); + } + return names; +} + +function getDefaultValueForSchema(propSchema: JsonSchema): unknown { + const defaultValue = getSchemaDefault(propSchema); + if (defaultValue !== undefined) return defaultValue; + + const enumValues = getSchemaEnumValues(propSchema); + if (enumValues.length > 0) return enumValues[0]; + + switch (getSchemaType(propSchema)) { + case 'string': + return ''; + case 'number': + case 'integer': + return 0; + case 'boolean': + return false; + case 'array': + return []; + case 'object': + return {}; + default: + return null; + } +} + +export function buildDefaultConfig( + configSchema: unknown +): Record { + const schema = asSchema(configSchema); + if (!schema) return {}; + + const normalized = normalizeSchema(schema, schema); + if (!normalized) return {}; + + const properties = getSchemaProperties(normalized); + const required = new Set(getSchemaRequiredProperties(normalized)); + const config: Record = {}; + + // Include ALL properties — required ones get type-appropriate defaults, + // optional ones with explicit defaults get those defaults. + for (const [propName, rawPropSchema] of Object.entries(properties)) { + const propSchema = normalizeSchema(rawPropSchema, schema); + if (!propSchema) continue; + + const explicitDefault = getSchemaDefault(propSchema); + if (required.has(propName) || explicitDefault !== undefined) { + config[propName] = getDefaultValueForSchema(propSchema); + } + } + + return config; +} + +export function findEvaluatorConfigEdit( + text: string, + previousNames: Map, + evaluators: JsonEditorEvaluatorOption[] | undefined +): { offset: number; length: number; newText: string } | null { + const tree = parseTree(text); + if (!tree) return null; + + const conditionNode = findNodeAtLocation(tree, ['condition']); + const result = new Map(); + collectEvaluatorNames(conditionNode, result); + + for (const [key, { name, configNode, nameNode }] of result) { + const prevName = previousNames.get(key); + if (prevName === undefined || prevName === name) continue; + + const evaluator = evaluators?.find((e) => e.id === name); + if (!evaluator) continue; + + const defaultConfig = buildDefaultConfig(evaluator.configSchema); + const configJson = JSON.stringify(defaultConfig, null, 2); + + if (configNode) { + // Replace existing config + return { + offset: configNode.offset, + length: configNode.length, + newText: configJson, + }; + } + + // No config property yet — insert after the name property. + // Find the end of the "name": "value" property in the source text. + const nameEnd = nameNode.offset + nameNode.length; + return { + offset: nameEnd, + length: 0, + newText: `,\n"config": ${configJson}`, + }; + } + + return null; +} + +export function findSteeringContextEdit( + text: string, + previousDecision: string | null +): { offset: number; length: number; newText: string } | null { + const tree = parseTree(text); + if (!tree) return null; + + const decisionNode = findNodeAtLocation(tree, ['action', 'decision']); + if (!decisionNode || typeof decisionNode.value !== 'string') return null; + + const currentDecision = decisionNode.value; + if (currentDecision === previousDecision) return null; + + if (currentDecision === 'steer') { + // Add steering_context if missing + const steeringNode = findNodeAtLocation(tree, [ + 'action', + 'steering_context', + ]); + if (!steeringNode) { + const decisionEnd = decisionNode.offset + decisionNode.length; + return { + offset: decisionEnd, + length: 0, + newText: `,\n"steering_context": {"message": "Please correct your response."}`, + }; + } + } else if (previousDecision === 'steer') { + // Remove steering_context when switching away from steer + const actionNode = findNodeAtLocation(tree, ['action']); + if (actionNode?.type === 'object' && actionNode.children) { + for (const prop of actionNode.children) { + const key = prop.children?.[0]; + if (key?.value === 'steering_context') { + // Find range including the preceding comma + let start = prop.offset; + while (start > 0 && /[\s,]/.test(text[start - 1])) { + start -= 1; + } + return { + offset: start, + length: prop.offset + prop.length - start, + newText: '', + }; + } + } + } + } + + return null; +} + +const MAX_HINT_VALUES = 6; + +function getStringValueAtPath( + tree: JsonNode | undefined, + path: JsonPath +): string | null { + if (!tree) return null; + const node = findNodeAtLocation(tree, path); + return typeof node?.value === 'string' ? node.value : null; +} + +export function getEmptyValueHints( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + context: JsonEditorAutocompleteContext +): Array<{ range: import('monaco-editor').IRange; hint: string }> { + const text = model.getValue(); + const tree = parseTree(text); + if (!tree) return []; + + const hints: Array<{ range: import('monaco-editor').IRange; hint: string }> = + []; + + // Hints for empty string values + const emptyStringPattern = /:\s*""/g; + let match; + + while ((match = emptyStringPattern.exec(text)) !== null) { + const offset = match.index + match[0].length - 1; + const location = getLocation(text, offset); + if (location.isAtPropertyKey) continue; + + const pos = model.getPositionAt(offset); + const range = new monaco.Range( + pos.lineNumber, + pos.column, + pos.lineNumber, + pos.column + ); + + const activeEvaluator = resolveActiveEvaluator( + context, + tree, + location.path + ); + + if (isEvaluatorNameLocation(location.path) && context.evaluators?.length) { + const names = context.evaluators.map((e) => e.id); + const display = names.slice(0, MAX_HINT_VALUES); + const hint = + display.join(' | ') + + (names.length > MAX_HINT_VALUES ? ' | ...' : ''); + hints.push({ range, hint: ` ${hint}` }); + continue; + } + + if (isSelectorPathLocation(location.path)) { + hints.push({ + range, + hint: ' * | input | output | context | ...', + }); + continue; + } + + const schemaCursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + location.path + ); + if (!schemaCursor.schema) continue; + + const enumValues = getSchemaEnumValues(schemaCursor.schema); + if (enumValues.length > 0 && enumValues.length <= MAX_HINT_VALUES) { + hints.push({ + range, + hint: ` ${enumValues.map(String).join(' | ')}`, + }); + } + } + + return hints; +} + +// Default Monaco JSON mode configuration with completionItems disabled. +// We disable the built-in JSON completion provider to avoid duplicate suggestions +export function setupJsonEditorLanguageSupport( + monaco: MonacoModule, + context: JsonEditorAutocompleteContext +) { + const jsonDefaults = ( + monaco.languages.json as unknown as { + jsonDefaults?: { + setDiagnosticsOptions: (options: { + validate: boolean; + allowComments: boolean; + schemas: Array<{ + fileMatch: string[]; + uri: string; + schema: JsonSchema; + }>; + }) => void; + }; + } + ).jsonDefaults; + + // Validate JSON syntax only — don't pass schema to avoid Monaco's built-in + // completions duplicating our custom suggestions. Monaco 0.55's + // setModeConfiguration({ completionItems: false }) doesn't reliably disable + // the built-in provider. Server-side validation handles schema errors. + jsonDefaults?.setDiagnosticsOptions({ + validate: true, + allowComments: false, + schemas: [], + }); + + const hoverDisposable = monaco.languages.registerHoverProvider('json', { + provideHover(model, position) { + if (model.uri.toString() !== context.modelUri) return null; + if (!context.schema) return null; + + const text = model.getValue(); + const offset = model.getOffsetAt(position); + const tree = parseTree(text); + const location = getLocation(text, offset); + if (!location.path.length) return null; + + const rootSchema = asSchema(context.schema); + const activeEvaluator = resolveActiveEvaluator( + context, + tree, + location.path + ); + const cursor = resolveSchemaAtJsonPath( + context, + activeEvaluator, + location.isAtPropertyKey ? location.path.slice(0, -1) : location.path + ); + + // For property keys, show the property's schema description + if (location.isAtPropertyKey) { + const propName = location.path[location.path.length - 1]; + if (typeof propName !== 'string' || !cursor.schema) return null; + const propSchema = getSchemaAtProperty( + cursor.schema, + propName, + cursor.rootSchema + ); + const desc = getSchemaDescription(propSchema); + const title = getSchemaTitle(propSchema); + if (!desc && !title) return null; + + const word = model.getWordAtPosition(position); + const range = word + ? new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ) + : undefined; + + return { + range, + contents: [ + { + value: `**${title ?? propName}**${desc ? '\n\n' + desc : ''}`, + }, + ], + }; + } + + // For values, show the value's schema info + if (cursor.schema) { + const desc = getSchemaDescription(cursor.schema); + const title = getSchemaTitle(cursor.schema); + const enumVals = getSchemaEnumValues(cursor.schema); + if (!desc && !title && enumVals.length === 0) return null; + + const parts: string[] = []; + if (title) parts.push(`**${title}**`); + if (desc) parts.push(desc); + if (enumVals.length > 0) + parts.push(`Values: \`${enumVals.join('` | `')}\``); + + const word = model.getWordAtPosition(position); + const range = word + ? new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ) + : undefined; + + return { + range, + contents: [{ value: parts.join('\n\n') }], + }; + } + + return null; + }, + }); + + const disposable = monaco.languages.registerCompletionItemProvider('json', { + triggerCharacters: COMPLETION_TRIGGER_CHARACTERS, + provideCompletionItems(model, position) { + if (model.uri.toString() !== context.modelUri) { + return { suggestions: [] }; + } + + return { + suggestions: getJsonEditorCompletionItems( + monaco, + model, + position, + context + ), + }; + }, + }); + + const codeActionDisposable = registerConditionCodeActions(monaco, context); + + return () => { + hoverDisposable.dispose(); + disposable.dispose(); + codeActionDisposable.dispose(); + }; +} + +// --------------------------------------------------------------------------- +// Condition Code Actions (lightbulb refactoring) +// --------------------------------------------------------------------------- + +const LEAF_CONDITION_TEMPLATE = { + selector: { path: '*' }, + evaluator: { name: '', config: {} }, +}; + +function findConditionNodeAtOffset( + tree: JsonNode | undefined, + offset: number +): { + node: JsonNode; + isLeaf: boolean; + isArray: boolean; + arrayKey: string | null; +} | null { + if (!tree) return null; + + const conditionNode = findNodeAtLocation(tree, ['condition']); + if (!conditionNode) return null; + + return findConditionAtOffset(conditionNode, offset); +} + +function findConditionAtOffset( + node: JsonNode, + offset: number +): { + node: JsonNode; + isLeaf: boolean; + isArray: boolean; + arrayKey: string | null; +} | null { + if (offset < node.offset || offset > node.offset + node.length) return null; + + if (node.type === 'object' && node.children) { + for (const prop of node.children) { + const key = prop.children?.[0]?.value; + const value = prop.children?.[1]; + if (!value) continue; + + if (key === 'and' || key === 'or') { + if (value.type === 'array' && value.children) { + // Check if offset is inside an array item + for (const item of value.children) { + const inner = findConditionAtOffset(item, offset); + if (inner) return inner; + } + // Offset is in the array but not inside a specific item + if (offset >= value.offset && offset <= value.offset + value.length) { + return { + node, + isLeaf: false, + isArray: true, + arrayKey: key as string, + }; + } + } + } else if (key === 'not' && value.type === 'object') { + const inner = findConditionAtOffset(value, offset); + if (inner) return inner; + } + } + + // We're on this object node itself + const hasSelector = !!findNodeAtLocation(node, ['selector']); + const hasEvaluator = !!findNodeAtLocation(node, ['evaluator']); + const hasAnd = !!findNodeAtLocation(node, ['and']); + const hasOr = !!findNodeAtLocation(node, ['or']); + const hasNot = !!findNodeAtLocation(node, ['not']); + const isLeaf = (hasSelector || hasEvaluator) && !hasAnd && !hasOr; + + return { + node, + isLeaf, + isArray: false, + arrayKey: hasAnd ? 'and' : hasOr ? 'or' : hasNot ? 'not' : null, + }; + } + + return null; +} + +function registerConditionCodeActions( + monaco: MonacoModule, + context: JsonEditorAutocompleteContext +) { + return monaco.languages.registerCodeActionProvider('json', { + provideCodeActions(model, range) { + if (model.uri.toString() !== context.modelUri) + return { actions: [], dispose() {} }; + if (context.mode !== 'control') return { actions: [], dispose() {} }; + + const text = model.getValue(); + const tree = parseTree(text); + if (!tree) return { actions: [], dispose() {} }; + + const offset = model.getOffsetAt(range.getStartPosition()); + const condCtx = findConditionNodeAtOffset(tree, offset); + if (!condCtx) return { actions: [], dispose() {} }; + + const actions: import('monaco-editor').languages.CodeAction[] = []; + const { node, isLeaf, isArray, arrayKey } = condCtx; + + const candidates: ( + | import('monaco-editor').languages.CodeAction + | null + )[] = []; + + if (isLeaf) { + candidates.push( + buildNodeTransformAction( + monaco, + model, + node, + 'Wrap in AND (add another condition)', + (p) => ({ and: [p, LEAF_CONDITION_TEMPLATE] }) + ), + buildNodeTransformAction( + monaco, + model, + node, + 'Wrap in OR (add another condition)', + (p) => ({ or: [p, LEAF_CONDITION_TEMPLATE] }) + ), + buildNodeTransformAction(monaco, model, node, 'Wrap in NOT', (p) => ({ + not: p, + })) + ); + } + + if (isArray && (arrayKey === 'and' || arrayKey === 'or')) { + const otherKey = arrayKey === 'and' ? 'or' : 'and'; + candidates.push( + buildNodeTransformAction( + monaco, + model, + node, + `Add condition to ${arrayKey.toUpperCase()}`, + (p) => { + const o = p as Record; + const a = o[arrayKey]; + if (!Array.isArray(a)) return undefined; + return { ...o, [arrayKey]: [...a, LEAF_CONDITION_TEMPLATE] }; + } + ), + buildNodeTransformAction( + monaco, + model, + node, + `Convert ${arrayKey.toUpperCase()} to ${otherKey.toUpperCase()}`, + (p) => { + const o = p as Record; + const a = o[arrayKey]; + delete o[arrayKey]; + return { ...o, [otherKey]: a }; + } + ) + ); + } + + if (arrayKey === 'not') { + candidates.push( + buildNodeTransformAction( + monaco, + model, + node, + 'Remove NOT (unwrap)', + (p) => (p as Record).not + ) + ); + } + + for (const action of candidates) { + if (action) actions.push(action); + } + + return { actions, dispose() {} }; + }, + }); +} + +function buildNodeTransformAction( + monaco: MonacoModule, + model: import('monaco-editor').editor.ITextModel, + node: JsonNode, + title: string, + transform: (parsed: unknown) => unknown +): import('monaco-editor').languages.CodeAction | null { + // Parse the full document, apply the transform to the target node, + // then re-serialize the whole document. This produces a single edit + // that replaces the entire content with properly formatted JSON, + // making undo a clean single-step revert. + const fullText = model.getValue(); + const nodeText = fullText.substring(node.offset, node.offset + node.length); + let parsed: unknown; + try { + parsed = JSON.parse(nodeText); + } catch { + return null; + } + + const result = transform(parsed); + if (result === undefined) return null; + + // Rebuild full document with the transformed node + const newNodeText = JSON.stringify(result); + const rawDoc = + fullText.substring(0, node.offset) + + newNodeText + + fullText.substring(node.offset + node.length); + + let newText: string; + try { + newText = JSON.stringify(JSON.parse(rawDoc), null, 2); + } catch { + // Fallback: just replace the node + newText = + fullText.substring(0, node.offset) + + JSON.stringify(result, null, 2) + + fullText.substring(node.offset + node.length); + } + + const fullRange = model.getFullModelRange(); + + return { + title, + kind: 'refactor', + edit: { + edits: [ + { + resource: model.uri, + textEdit: { + range: new monaco.Range( + fullRange.startLineNumber, + fullRange.startColumn, + fullRange.endLineNumber, + fullRange.endColumn + ), + text: newText, + }, + versionId: model.getVersionId(), + }, + ], + }, + }; +} diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/json-editor-view.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/json-editor-view.tsx index 1c3b2455..297f692b 100644 --- a/ui/src/core/page-components/agent-detail/modals/edit-control/json-editor-view.tsx +++ b/ui/src/core/page-components/agent-detail/modals/edit-control/json-editor-view.tsx @@ -1,21 +1,205 @@ -import { Box, Text, Textarea } from '@mantine/core'; -import { useDebouncedValue } from '@mantine/hooks'; -import { useEffect, useRef } from 'react'; +import { ActionIcon, Box, Group, Text, Tooltip } from '@mantine/core'; +import { useClipboard, useDebouncedValue } from '@mantine/hooks'; +import { + IconClipboardCheck, + IconClipboardCopy, + IconCode, +} from '@tabler/icons-react'; +import dynamic from 'next/dynamic'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isApiError } from '@/core/api/errors'; import { - labelPropsInline, - LabelWithTooltip, -} from '@/core/components/label-with-tooltip'; + extractEvaluatorNames, + findEvaluatorConfigEdit, + findSteeringContextEdit, + fixJsonCommas, + getEmptyValueHints, + getJsonEditorCompletionItems, + setupJsonEditorLanguageSupport, +} from '@/components/json-editor-monaco/json-editor-monaco-language'; +import { isApiError } from '@/core/api/errors'; +import { LabelWithTooltip } from '@/core/components/label-with-tooltip'; import { ApiErrorAlert } from './api-error-alert'; import type { JsonEditorViewProps } from './types'; +const MonacoEditor = dynamic( + async () => (await import('@monaco-editor/react')).default, + { ssr: false } +); + +type MonacoModule = typeof import('monaco-editor'); +type MonacoEditorInstance = + import('monaco-editor').editor.IStandaloneCodeEditor; + +type JsonEditorTestElement = HTMLDivElement & { + __getJsonEditorValue?: () => string; + __getJsonEditorLanguageId?: () => string | null; + __setJsonEditorValue?: (value: string) => void; + __isJsonEditorReady?: () => boolean; + __focusJsonEditorAt?: (lineNumber: number, column: number) => void; + __triggerJsonEditorSuggest?: () => void; + __getJsonEditorSuggestions?: ( + lineNumber: number, + column: number + ) => Array<{ label: string; detail?: string }>; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + const DEFAULT_HEIGHT = 400; const DEFAULT_VALIDATE_DEBOUNCE_MS = 500; const DEFAULT_LABEL = 'Configuration (JSON)'; const DEFAULT_TOOLTIP = 'Raw JSON configuration'; const DEFAULT_TEST_ID = 'raw-json-textarea'; +const DEFAULT_EDITOR_MODE = 'evaluator-config'; +const HINT_DEBOUNCE_MS = 300; +const COMMA_FIX_DEBOUNCE_MS = 800; +const CURSOR_TRIGGER_DEBOUNCE_MS = 50; +const HINT_CSS_CLASS = 'json-editor-value-hint'; + +// Dynamic hint styles — each unique hint text gets a CSS class with ::after content. +// Monaco 0.55 doesn't support `after.content` in decorations, so we use +// `afterContentClassName` with CSS `::after` pseudo-elements instead. +let hintStyleEl: HTMLStyleElement | null = null; +let hintClassCounter = 0; +const hintClassMap = new Map(); + +function getHintClassName(hintText: string): string { + let cls = hintClassMap.get(hintText); + if (cls) return cls; + + cls = `${HINT_CSS_CLASS}-${hintClassCounter++}`; + hintClassMap.set(hintText, cls); + + if (typeof document !== 'undefined') { + if (!hintStyleEl) { + hintStyleEl = document.createElement('style'); + hintStyleEl.id = 'json-editor-hint-styles'; + document.head.appendChild(hintStyleEl); + } + const escaped = hintText.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + hintStyleEl.textContent += `.${cls}::after { content: "${escaped}"; color: var(--mantine-color-gray-5); font-style: italic; pointer-events: none; }\n`; + } + return cls; +} + +const EDITOR_OPTIONS: import('monaco-editor').editor.IStandaloneEditorConstructionOptions = + { + automaticLayout: true, + quickSuggestions: false, + suggestOnTriggerCharacters: true, + wordBasedSuggestions: 'off', + suggest: { + showWords: false, + preview: true, + showIcons: true, + insertMode: 'replace', + }, + snippetSuggestions: 'inline', + acceptSuggestionOnEnter: 'off', + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace', + fontSize: 12, + formatOnPaste: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + tabSize: 2, + insertSpaces: true, + wordWrap: 'off', + bracketPairColorization: { enabled: true }, + guides: { bracketPairs: true, indentation: true }, + stickyScroll: { enabled: true }, + padding: { top: 8, bottom: 8 }, + folding: true, + showFoldingControls: 'mouseover', + renderLineHighlight: 'line', + cursorSmoothCaretAnimation: 'on', + smoothScrolling: true, + }; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isSuggestWidgetVisible(editor: MonacoEditorInstance): boolean { + return ( + editor + .getDomNode() + ?.querySelector('.suggest-widget') + ?.classList.contains('visible') ?? false + ); +} + +function replaceAllContent( + editor: MonacoEditorInstance, + newText: string, + source: string +) { + const model = editor.getModel(); + if (!model) return; + editor.executeEdits(source, [ + { range: model.getFullModelRange(), text: newText }, + ]); + const pos = editor.getPosition(); + if (pos && pos.lineNumber > model.getLineCount()) { + editor.setPosition({ lineNumber: model.getLineCount(), column: 1 }); + } +} + +function tryFormat(text: string): string | null { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return null; + } +} + +/** Check if the cursor is at a position where auto-triggering suggestions is useful. */ +function shouldAutoTriggerSuggest( + line: string | undefined, + column: number, + skipStringTrigger: boolean, + hasSuggestions: () => boolean +): boolean { + if (!line) return false; + const beforeCursor = line.substring(0, column - 1); + const afterCursor = line.substring(column - 1); + + // Blank / comma-only line — always trigger (even after Enter) + if (/^\s*,?\s*$/.test(line)) return true; + + // Don't trigger string suggestions after typing + if (skipStringTrigger) return false; + + // Check if inside a string value (not a property key) + const quotesBefore = beforeCursor.split('"').length - 1; + const isInString = quotesBefore % 2 === 1 && /^[^"]*"/.test(afterCursor); + if (!isInString) return false; + if (/^\s*:/.test(afterCursor.replace(/^[^"]*"/, ''))) return false; + + const openIdx = beforeCursor.lastIndexOf('"'); + const closeIdx = afterCursor.indexOf('"'); + const contentLen = + openIdx >= 0 && closeIdx >= 0 + ? beforeCursor.length - openIdx - 1 + closeIdx + : 999; + + // Short strings: always trigger (browsing options) + if (contentLen <= 2) return true; + + // Longer strings: trigger if our provider has suggestions (enum values, + // evaluator names, selector paths). This covers all domain fields + // including evaluator config enums (logic, match_on, mode, etc.) + // without hardcoding field names. + return hasSuggestions(); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export const JsonEditorView = ({ jsonText, @@ -32,10 +216,344 @@ export const JsonEditorView = ({ tooltip = DEFAULT_TOOLTIP, helperText, testId = DEFAULT_TEST_ID, + editorMode = DEFAULT_EDITOR_MODE, + schema, + evaluators, + activeEvaluatorId, + steps, }: JsonEditorViewProps) => { + const [isDarkMode, setIsDarkMode] = useState(false); const [debouncedJsonText] = useDebouncedValue(jsonText, validateDebounceMs); + const [mounted, setMounted] = useState(false); const abortControllerRef = useRef(null); + const editorRef = useRef(null); + const monacoRef = useRef(null); + const editorRootRef = useRef(null); + const cleanupLanguageRef = useRef<(() => void) | null>(null); + + const modelUri = useMemo( + () => `inmemory://agent-control/${testId}.json`, + [testId] + ); + const autocompleteContext = useMemo( + () => ({ + mode: editorMode, + modelUri, + schema, + evaluators, + activeEvaluatorId, + steps, + }), + [activeEvaluatorId, editorMode, evaluators, modelUri, schema, steps] + ); + + const clipboard = useClipboard({ timeout: 1500 }); + + // --- Dark mode --- + useEffect(() => { + const detect = () => + setIsDarkMode( + document.documentElement.getAttribute('data-mantine-color-scheme') === + 'dark' + ); + detect(); + const obs = new MutationObserver(detect); + obs.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-mantine-color-scheme'], + }); + return () => obs.disconnect(); + }, []); + + // --- Toolbar --- + const formatDocument = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const commaFixed = fixJsonCommas(editor.getValue()); + const formatted = tryFormat(commaFixed); + if (formatted && formatted !== editor.getValue()) { + replaceAllContent(editor, formatted, 'format'); + handleJsonChange(formatted); + } else if (commaFixed !== editor.getValue()) { + replaceAllContent(editor, commaFixed, 'comma-fix'); + handleJsonChange(commaFixed); + } + }, [handleJsonChange]); + + const copyToClipboard = useCallback(() => { + clipboard.copy(editorRef.current?.getValue() ?? jsonText); + }, [clipboard, jsonText]); + + // --- Mount --- + const handleEditorMount = useCallback( + (editor: MonacoEditorInstance, monaco: MonacoModule) => { + editorRef.current = editor; + monacoRef.current = monaco; + setMounted(true); + }, + [] + ); + + // --- Language support --- + useEffect(() => { + if (!mounted || !monacoRef.current) return; + cleanupLanguageRef.current?.(); + cleanupLanguageRef.current = setupJsonEditorLanguageSupport( + monacoRef.current, + autocompleteContext + ); + return () => { + cleanupLanguageRef.current?.(); + cleanupLanguageRef.current = null; + }; + }, [mounted, autocompleteContext]); + // --- Unified content-change listener --- + useEffect(() => { + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco || !mounted) return; + + let decorationIds: string[] = []; + const updateHints = () => { + const model = editor.getModel(); + if (!model) return; + try { + decorationIds = editor.deltaDecorations( + decorationIds, + getEmptyValueHints(monaco, model, autocompleteContext).map((h) => ({ + range: h.range, + options: { + afterContentClassName: getHintClassName(h.hint), + }, + })) + ); + } catch { + decorationIds = editor.deltaDecorations(decorationIds, []); + } + }; + updateHints(); + + let prevEvalNames = extractEvaluatorNames(editor.getValue()); + let prevDecision: string | null = null; + try { + prevDecision = JSON.parse(editor.getValue())?.action?.decision ?? null; + } catch { + /* ignore */ + } + let isProgrammaticEdit = false; + let hintTimer: number | null = null; + let commaTimer: number | null = null; + + const applyEdit = ( + edit: { offset: number; length: number; newText: string }, + source: string + ) => { + const model = editor.getModel(); + if (!model) return; + const start = model.getPositionAt(edit.offset); + const end = model.getPositionAt(edit.offset + edit.length); + queueMicrotask(() => { + isProgrammaticEdit = true; + editor.executeEdits(source, [ + { + range: { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + }, + text: edit.newText, + }, + ]); + const formatted = tryFormat(editor.getValue()); + if (formatted) { + replaceAllContent(editor, formatted, 'reformat'); + handleJsonChange(formatted); + } else { + handleJsonChange(editor.getValue()); + } + }); + }; + + const disposable = editor.onDidChangeModelContent((_e) => { + if (isProgrammaticEdit) { + isProgrammaticEdit = false; + return; + } + + const text = editor.getValue(); + + // No auto-reformat here — code actions produce formatted JSON by replacing + // the full document (see buildNodeTransformAction). Auto-reformatting would + // create a second undo entry that breaks Ctrl+Z. + + // Debounced hints + if (hintTimer) window.clearTimeout(hintTimer); + hintTimer = window.setTimeout(updateHints, HINT_DEBOUNCE_MS); + + // Debounced comma fix (only on blur, only if result is valid) + if (commaTimer) window.clearTimeout(commaTimer); + commaTimer = window.setTimeout(() => { + if (isSuggestWidgetVisible(editor) || editor.hasTextFocus()) return; + const current = editor.getValue(); + const fixed = fixJsonCommas(current); + if (fixed !== current && tryFormat(fixed)) { + isProgrammaticEdit = true; + replaceAllContent(editor, fixed, 'auto-comma-fix'); + handleJsonChange(fixed); + } + }, COMMA_FIX_DEBOUNCE_MS); + + // Immediate: dependent field updates (control mode only) + if (editorMode === 'control') { + const evalEdit = findEvaluatorConfigEdit( + text, + prevEvalNames, + evaluators + ); + prevEvalNames = extractEvaluatorNames(text); + if (evalEdit) { + applyEdit(evalEdit, 'evaluator-config-update'); + return; + } + + const steerEdit = findSteeringContextEdit(text, prevDecision); + try { + prevDecision = JSON.parse(text)?.action?.decision ?? null; + } catch { + /* ignore */ + } + if (steerEdit) { + applyEdit(steerEdit, 'steering-context-update'); + } + } + }); + + return () => { + if (hintTimer) window.clearTimeout(hintTimer); + if (commaTimer) window.clearTimeout(commaTimer); + disposable.dispose(); + editor.deltaDecorations(decorationIds, []); + }; + }, [mounted, autocompleteContext, editorMode, evaluators, handleJsonChange]); + + // --- Cursor auto-trigger --- + useEffect(() => { + const editor = editorRef.current; + if (!editor || !mounted) return; + + // Track content changes to suppress string-value auto-trigger after typing. + // Blank line triggers still fire (Enter creates a new line → want suggestions). + let contentJustChanged = false; + const contentDisposable = editor.onDidChangeModelContent((e) => { + // Only flag as "typing" for small single-char edits, not large replacements + // (setValue, code actions, reformat). This prevents suppressing auto-trigger + // after programmatic content changes. + const isSmallEdit = + e.changes.length === 1 && + e.changes[0].text.length <= 2 && + !e.changes[0].text.includes('\n'); + contentJustChanged = isSmallEdit; + }); + + let timeout: number | null = null; + const disposable = editor.onDidChangeCursorPosition(() => { + if (timeout) window.clearTimeout(timeout); + timeout = window.setTimeout(() => { + try { + const pos = editor.getPosition(); + const model = editor.getModel(); + if (!pos || !model) return; + if (pos.lineNumber < 1 || pos.lineNumber > model.getLineCount()) + return; + + const wasTyping = contentJustChanged; + contentJustChanged = false; + + const trigger = shouldAutoTriggerSuggest( + model.getLineContent(pos.lineNumber), + pos.column, + wasTyping, + () => + monacoRef.current + ? getJsonEditorCompletionItems( + monacoRef.current, + model, + pos, + autocompleteContext + ).length > 0 + : false + ); + if (trigger) { + editor.trigger('cursor', 'editor.action.triggerSuggest', {}); + } + } catch { + // Ignore — stale cursor during undo + } + }, CURSOR_TRIGGER_DEBOUNCE_MS); + }); + + return () => { + if (timeout) window.clearTimeout(timeout); + disposable.dispose(); + contentDisposable.dispose(); + }; + }, [mounted, autocompleteContext]); + + // --- Test harness --- + useEffect(() => { + const root = editorRootRef.current; + if (!root) return; + root.__getJsonEditorValue = () => editorRef.current?.getValue() ?? ''; + root.__getJsonEditorLanguageId = () => + editorRef.current?.getModel()?.getLanguageId() ?? null; + root.__isJsonEditorReady = () => + Boolean(editorRef.current && monacoRef.current); + root.__focusJsonEditorAt = (l, c) => { + if (!editorRef.current || !monacoRef.current) return; + editorRef.current.setPosition(new monacoRef.current.Position(l, c)); + editorRef.current.focus(); + }; + root.__setJsonEditorValue = (v) => { + editorRef.current?.setValue(v); + editorRef.current?.focus(); + handleJsonChange(v); + }; + root.__triggerJsonEditorSuggest = () => { + editorRef.current?.focus(); + editorRef.current?.trigger( + 'keyboard', + 'editor.action.triggerSuggest', + {} + ); + }; + root.__getJsonEditorSuggestions = (l, c) => { + if (!editorRef.current || !monacoRef.current) return []; + const model = editorRef.current.getModel(); + if (!model) return []; + return getJsonEditorCompletionItems( + monacoRef.current, + model, + new monacoRef.current.Position(l, c), + autocompleteContext + ).map((item) => ({ + label: typeof item.label === 'string' ? item.label : item.label.label, + detail: typeof item.detail === 'string' ? item.detail : undefined, + })); + }; + return () => { + delete root.__getJsonEditorValue; + delete root.__getJsonEditorLanguageId; + delete root.__isJsonEditorReady; + delete root.__focusJsonEditorAt; + delete root.__setJsonEditorValue; + delete root.__triggerJsonEditorSuggest; + delete root.__getJsonEditorSuggestions; + }; + }, [autocompleteContext, handleJsonChange]); + + // --- Validation --- useEffect(() => { if (!onValidateConfig) return; if (!debouncedJsonText) { @@ -58,7 +576,6 @@ export const JsonEditorView = ({ abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; - setJsonError?.(null); onValidationStatusChange?.('validating'); onValidateConfig(parsed, { signal: controller.signal }) @@ -77,7 +594,6 @@ export const JsonEditorView = ({ onValidationStatusChange?.('invalid'); } }); - return () => controller.abort(); }, [ debouncedJsonText, @@ -87,24 +603,75 @@ export const JsonEditorView = ({ setValidationError, ]); + // --- Render --- return ( -