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/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index 4fa44dbb..f28e42a2 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -100,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): @@ -212,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: diff --git a/server/tests/test_controls_validation.py b/server/tests/test_controls_validation.py index 2761bdd3..b6d53562 100644 --- a/server/tests/test_controls_validation.py +++ b/server/tests/test_controls_validation.py @@ -318,3 +318,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 1d8748ac..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", @@ -56,6 +67,10 @@ "@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", @@ -71,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", @@ -87,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 49f92450..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)) @@ -59,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 @@ -99,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 @@ -114,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) @@ -150,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: @@ -191,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'} @@ -212,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'} @@ -228,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==} @@ -243,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} @@ -487,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: @@ -559,6 +812,9 @@ 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==} @@ -646,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'} @@ -661,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==} @@ -815,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==} @@ -924,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] @@ -1027,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: @@ -1202,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==} @@ -1228,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'} @@ -1418,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'} @@ -1658,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==} @@ -2459,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'} @@ -2565,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==} @@ -2700,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'} @@ -2886,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'} @@ -3000,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': {} @@ -3015,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': @@ -3035,32 +3679,168 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@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 - '@emnapi/core@1.8.1': - dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 + '@esbuild/linux-x64@0.25.12': optional: true - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@emnapi/wasi-threads@1.1.0': - dependencies: - tslib: 2.8.1 + '@esbuild/netbsd-x64@0.25.12': optional: true - '@emotion/is-prop-valid@1.4.0': - dependencies: - '@emotion/memoize': 0.9.0 + '@esbuild/openbsd-arm64@0.25.12': + optional: true - '@emotion/memoize@0.9.0': {} + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: @@ -3260,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) @@ -3338,6 +4134,8 @@ 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 @@ -3400,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 @@ -3427,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)': @@ -3553,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': {} @@ -3689,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 @@ -3748,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 @@ -3945,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 @@ -3967,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 @@ -4215,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: {} @@ -4521,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: @@ -5271,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 @@ -5399,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 @@ -5601,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 @@ -5811,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..cdb9faf6 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts @@ -0,0 +1,17 @@ +export { + applyTextEdit, + buildCodeMirrorInlineServerValidationErrorsExtension, + buildCodeMirrorJsonExtensions, + buildCodeMirrorRefactorLightbulbExtension, + buildCodeMirrorStandaloneDebugExtensions, + 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..e27d11ae --- /dev/null +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx @@ -0,0 +1,718 @@ +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, + 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((e) => e.field == null) + .map((e) => ({ field: e.field, message: e.message })); + }, [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..0e0602a6 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/extensions.ts @@ -0,0 +1,1170 @@ +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, + 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..c1879bd0 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/format.ts @@ -0,0 +1,88 @@ +import { + findNodeAtLocation, + getLocation, + type ParseError, + parseTree, +} from 'jsonc-parser'; + +/** + * 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 = text.replace(/,(\s*[}\]])/g, '$1'); + 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..79bb1c4a --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/index.ts @@ -0,0 +1,21 @@ +export { + applyTextEdit, + computeAutoEdit, + extractEvaluatorNames, +} from './auto-edits'; +export { + buildCodeMirrorInlineServerValidationErrorsExtension, + buildCodeMirrorJsonExtensions, + buildCodeMirrorRefactorLightbulbExtension, + buildCodeMirrorStandaloneDebugExtensions, + 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..09148c4a --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/inline-server-validation.ts @@ -0,0 +1,223 @@ +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 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..db2a9b04 --- /dev/null +++ b/ui/src/components/json-editor-monaco/json-editor-monaco-language.ts @@ -0,0 +1,1940 @@ +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 '@/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 ] + 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/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/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 058aa9c2..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, @@ -37,7 +39,6 @@ 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, @@ -50,6 +51,8 @@ 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( { @@ -120,6 +123,8 @@ export const EditControlContent = ({ useState(null); const [definitionValidationStatus, setDefinitionValidationStatus] = useState('idle'); + const [jsonEditorEngine, setJsonEditorEngine] = + useState('monaco'); const updateControl = useUpdateControl(); const updateControlMetadata = useUpdateControlMetadata(); @@ -465,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); @@ -770,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} @@ -790,25 +824,47 @@ export const EditControlContent = ({ {editorMode === 'json' ? ( - + {jsonEditorEngine === 'monaco' ? ( + + ) : ( + + )} ) : ( 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 f0b883fd..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 @@ -8,10 +8,6 @@ import { import dynamic from 'next/dynamic'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isApiError } from '@/core/api/errors'; -import { LabelWithTooltip } from '@/core/components/label-with-tooltip'; - -import { ApiErrorAlert } from './api-error-alert'; import { extractEvaluatorNames, findEvaluatorConfigEdit, @@ -20,7 +16,11 @@ import { getEmptyValueHints, getJsonEditorCompletionItems, setupJsonEditorLanguageSupport, -} from './json-editor-language'; +} 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( @@ -376,7 +376,7 @@ export const JsonEditorView = ({ }); }; - const disposable = editor.onDidChangeModelContent((e) => { + const disposable = editor.onDidChangeModelContent((_e) => { if (isProgrammaticEdit) { isProgrammaticEdit = false; return; diff --git a/ui/tests/ct/json-editor-codemirror.spec.tsx b/ui/tests/ct/json-editor-codemirror.spec.tsx new file mode 100644 index 00000000..942dae52 --- /dev/null +++ b/ui/tests/ct/json-editor-codemirror.spec.tsx @@ -0,0 +1,168 @@ +/// +/** @jsxImportSource playwright */ + +/** + * Playwright component tests: real browser, Vite bundle, no Next.js server. + * + * The pragma uses `playwright/jsx-runtime` (see `playwright` package exports). Root + * `tsconfig.json` uses `"jsx": "preserve"` for Next.js; without this, `mount()` would + * serialize raw function components and the CT bundle rejects them. + */ + +import { expect, test } from '@playwright/experimental-ct-react'; + +// One binding per import: CT Babel only strips/replaces imports whose specifiers are +// all JSX tag names; mixing `CT_JSON_EDITOR_TEST_ID` with the host leaves a real +// function reference and mount() fails to serialize the component. +import { JsonEditorCodeMirrorCtHost } from '../../src/components/json-editor-codemirror/json-editor-codemirror.playwright-story'; +import { CT_JSON_EDITOR_TEST_ID } from '../../src/components/json-editor-codemirror/json-editor-codemirror.playwright-story'; +import { + focusJsonEditorAt, + getJsonEditorSuggestions, + getJsonEditorValue, + setJsonEditorValue, +} from '../json-editor-bridge'; + +const EDITOR = CT_JSON_EDITOR_TEST_ID; + +test.describe('JsonEditorCodeMirror (component)', () => { + test('evaluator-config mode loads empty object document', async ({ + mount, + page, + }) => { + await mount(); + await expect( + page.getByTestId('json-editor-codemirror-ct-host') + ).toBeVisible(); + const raw = await getJsonEditorValue(page, EDITOR); + expect(JSON.parse(raw)).toEqual({}); + }); + + test('control mode loads minimal valid control document', async ({ + mount, + page, + }) => { + await mount(); + const raw = await getJsonEditorValue(page, EDITOR); + const parsed = JSON.parse(raw) as { + execution: string; + condition: unknown; + action: { decision: string }; + }; + expect(parsed.execution).toBe('server'); + expect(parsed.condition).toEqual({}); + expect(parsed.action).toEqual({ decision: 'allow' }); + }); + + test('setJsonEditorValue updates document', async ({ mount, page }) => { + await mount(); + const next = JSON.stringify({ threshold: 0.5, enabled: true }); + await setJsonEditorValue(page, EDITOR, next); + expect(JSON.parse(await getJsonEditorValue(page, EDITOR))).toEqual({ + threshold: 0.5, + enabled: true, + }); + }); + + test('keyboard typing inserts text at cursor', async ({ mount, page }) => { + await mount(); + expect(JSON.parse(await getJsonEditorValue(page, EDITOR))).toEqual({}); + + await focusJsonEditorAt(page, EDITOR, 1, 2); + await page.keyboard.type('"typedKey": 42', { delay: 15 }); + + expect(JSON.parse(await getJsonEditorValue(page, EDITOR))).toEqual({ + typedKey: 42, + }); + }); + + test('replace sample sets minified control JSON', async ({ mount, page }) => { + await mount(); + await page.getByTestId('ct-replace-sample').click(); + expect(JSON.parse(await getJsonEditorValue(page, EDITOR))).toEqual({ + execution: 'sdk', + condition: { selector: { path: '*' } }, + action: { decision: 'deny' }, + }); + }); + + test('format pretty-prints without changing parsed data', async ({ + mount, + page, + }) => { + await mount(); + await page.getByTestId('ct-replace-sample').click(); + const before = await getJsonEditorValue(page, EDITOR); + expect(before.includes('\n')).toBe(false); + + await page.getByRole('button', { name: 'Format document' }).click(); + + const after = await getJsonEditorValue(page, EDITOR); + expect(after.includes('\n')).toBe(true); + expect(JSON.parse(after)).toEqual(JSON.parse(before)); + }); + + test('toggle jsonError shows parent message', async ({ mount, page }) => { + await mount(); + await page.getByTestId('ct-toggle-json-error').click(); + await expect( + page.getByText('Simulated invalid JSON message from parent') + ).toBeVisible(); + await page.getByTestId('ct-toggle-json-error').click(); + await expect( + page.getByText('Simulated invalid JSON message from parent') + ).toHaveCount(0); + }); + + test('helperText is visible', async ({ mount, page }) => { + await mount(); + await expect( + page.getByText('Playwright CT mounts this host without a Next.js page.') + ).toBeVisible(); + }); + + test('control mode suggests root schema properties', async ({ + mount, + page, + }) => { + await mount(); + await setJsonEditorValue(page, EDITOR, '{}'); + const items = await getJsonEditorSuggestions(page, EDITOR, 1, 2); + const labels = items.map((i) => i.label); + expect(labels).toContain('condition'); + expect(labels).toContain('action'); + expect(labels).toContain('execution'); + }); + + test('remounting host switches mode document', async ({ mount, page }) => { + let host = await mount( + + ); + expect(JSON.parse(await getJsonEditorValue(page, EDITOR))).toEqual({}); + + await host.unmount(); + host = await mount(); + const controlParse = JSON.parse(await getJsonEditorValue(page, EDITOR)) as { + execution?: string; + }; + expect(controlParse.execution).toBe('server'); + + await host.unmount(); + await mount(); + expect(JSON.parse(await getJsonEditorValue(page, EDITOR))).toEqual({}); + }); + + test('invalid JSON stays readable; format does not silently fix', async ({ + mount, + page, + }) => { + await mount(); + const broken = '{"a":1'; + await setJsonEditorValue(page, EDITOR, broken); + expect(await getJsonEditorValue(page, EDITOR)).toBe(broken); + + await page.getByRole('button', { name: 'Format document' }).click(); + const after = await getJsonEditorValue(page, EDITOR); + expect(() => JSON.parse(after)).toThrow(); + }); +}); diff --git a/ui/tests/ct/playwright-jsx-runtime.d.ts b/ui/tests/ct/playwright-jsx-runtime.d.ts new file mode 100644 index 00000000..d093b75a --- /dev/null +++ b/ui/tests/ct/playwright-jsx-runtime.d.ts @@ -0,0 +1,8 @@ +/** + * `playwright` ships `jsx-runtime` at runtime (`exports["./jsx-runtime"]`) but does not + * declare `types` for that subpath. The `@jsxImportSource playwright` pragma in CT specs + * needs this module declaration so tsc can typecheck JSX. + */ +declare module 'playwright/jsx-runtime' { + export { Fragment, jsx, jsxs } from 'react/jsx-runtime'; +} diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts index 991e8086..569ac376 100644 --- a/ui/tests/fixtures.ts +++ b/ui/tests/fixtures.ts @@ -929,114 +929,12 @@ export async function mockApiRoutesWithAuthRequired(page: Page) { await mockRoutes.stats(page); } -export async function setJsonEditorValue( - page: Page, - testId: string, - value: string -) { - const locator = page.getByTestId(testId); - await expect(locator).toBeVisible(); - await page.waitForFunction((selector) => { - const element = document.querySelector(`[data-testid="${selector}"]`) as { - __setJsonEditorValue?: (nextValue: string) => void; - } | null; - - return typeof element?.__setJsonEditorValue === 'function'; - }, testId); - - await locator.evaluate((element, nextValue) => { - const target = element as { - __setJsonEditorValue?: (value: string) => void; - }; - - if (!target.__setJsonEditorValue) { - throw new Error('JSON editor bridge not available'); - } - - target.__setJsonEditorValue(nextValue); - }, value); -} - -export async function getJsonEditorSuggestions( - page: Page, - testId: string, - lineNumber: number, - column: number -) { - const locator = page.getByTestId(testId); - await expect(locator).toBeVisible(); - await page.waitForFunction((selector) => { - const element = document.querySelector(`[data-testid="${selector}"]`) as { - __isJsonEditorReady?: () => boolean; - __getJsonEditorSuggestions?: ( - line: number, - column: number - ) => Array<{ label: string; detail?: string }>; - } | null; - - return ( - typeof element?.__getJsonEditorSuggestions === 'function' && - element.__isJsonEditorReady?.() === true - ); - }, testId); - - return locator.evaluate( - (element, params) => { - const target = element as { - __getJsonEditorSuggestions?: ( - line: number, - column: number - ) => Array<{ label: string; detail?: string }>; - }; - - if (!target.__getJsonEditorSuggestions) { - throw new Error('JSON editor suggestions bridge not available'); - } - - return target.__getJsonEditorSuggestions( - params.lineNumber, - params.column - ); - }, - { lineNumber, column } - ); -} - -export async function focusJsonEditorAt( - page: Page, - testId: string, - lineNumber: number, - column: number -) { - const locator = page.getByTestId(testId); - await expect(locator).toBeVisible(); - await page.waitForFunction((selector) => { - const element = document.querySelector(`[data-testid="${selector}"]`) as { - __isJsonEditorReady?: () => boolean; - __focusJsonEditorAt?: (line: number, column: number) => void; - } | null; - - return ( - typeof element?.__focusJsonEditorAt === 'function' && - element.__isJsonEditorReady?.() === true - ); - }, testId); - - await locator.evaluate( - (element, params) => { - const target = element as { - __focusJsonEditorAt?: (line: number, column: number) => void; - }; - - if (!target.__focusJsonEditorAt) { - throw new Error('JSON editor focus bridge not available'); - } - - target.__focusJsonEditorAt(params.lineNumber, params.column); - }, - { lineNumber, column } - ); -} +export { + focusJsonEditorAt, + getJsonEditorSuggestions, + getJsonEditorValue, + setJsonEditorValue, +} from './json-editor-bridge'; /** * Extended test with mocked API diff --git a/ui/tests/json-editor-bridge.ts b/ui/tests/json-editor-bridge.ts new file mode 100644 index 00000000..c5d2086f --- /dev/null +++ b/ui/tests/json-editor-bridge.ts @@ -0,0 +1,138 @@ +import { expect, type Page } from '@playwright/test'; + +export async function getJsonEditorValue( + page: Page, + testId: string +): Promise { + const locator = page.getByTestId(testId); + await expect(locator).toBeVisible(); + await page.waitForFunction((selector) => { + const element = document.querySelector(`[data-testid="${selector}"]`) as { + __getJsonEditorValue?: () => string; + __isJsonEditorReady?: () => boolean; + } | null; + + return ( + typeof element?.__getJsonEditorValue === 'function' && + element.__isJsonEditorReady?.() === true + ); + }, testId); + + return locator.evaluate((element) => { + const target = element as { __getJsonEditorValue?: () => string }; + return target.__getJsonEditorValue?.() ?? ''; + }); +} + +export async function setJsonEditorValue( + page: Page, + testId: string, + value: string +) { + const locator = page.getByTestId(testId); + await expect(locator).toBeVisible(); + await page.waitForFunction((selector) => { + const element = document.querySelector(`[data-testid="${selector}"]`) as { + __setJsonEditorValue?: (nextValue: string) => void; + __isJsonEditorReady?: () => boolean; + } | null; + + return ( + typeof element?.__setJsonEditorValue === 'function' && + element.__isJsonEditorReady?.() === true + ); + }, testId); + + await locator.evaluate((element, nextValue) => { + const target = element as { + __setJsonEditorValue?: (v: string) => void; + }; + + if (!target.__setJsonEditorValue) { + throw new Error('JSON editor bridge not available'); + } + + target.__setJsonEditorValue(nextValue); + }, value); +} + +export async function getJsonEditorSuggestions( + page: Page, + testId: string, + lineNumber: number, + column: number +) { + const locator = page.getByTestId(testId); + await expect(locator).toBeVisible(); + await page.waitForFunction((selector) => { + const element = document.querySelector(`[data-testid="${selector}"]`) as { + __isJsonEditorReady?: () => boolean; + __getJsonEditorSuggestions?: ( + line: number, + col: number + ) => Array<{ label: string; detail?: string }>; + } | null; + + return ( + typeof element?.__getJsonEditorSuggestions === 'function' && + element.__isJsonEditorReady?.() === true + ); + }, testId); + + return locator.evaluate( + (element, params) => { + const target = element as { + __getJsonEditorSuggestions?: ( + line: number, + col: number + ) => Array<{ label: string; detail?: string }>; + }; + + if (!target.__getJsonEditorSuggestions) { + throw new Error('JSON editor suggestions bridge not available'); + } + + return target.__getJsonEditorSuggestions( + params.lineNumber, + params.column + ); + }, + { lineNumber, column } + ); +} + +export async function focusJsonEditorAt( + page: Page, + testId: string, + lineNumber: number, + column: number +) { + const locator = page.getByTestId(testId); + await expect(locator).toBeVisible(); + await page.waitForFunction((selector) => { + const element = document.querySelector(`[data-testid="${selector}"]`) as { + __isJsonEditorReady?: () => boolean; + __focusJsonEditorAt?: (line: number, col: number) => void; + } | null; + + return ( + typeof element?.__focusJsonEditorAt === 'function' && + element.__isJsonEditorReady?.() === true + ); + }, testId); + + await locator.evaluate( + (element, params) => { + const target = element as { + __focusJsonEditorAt?: (line: number, col: number) => void; + }; + + if (!target.__focusJsonEditorAt) { + throw new Error('JSON editor focus bridge not available'); + } + + target.__focusJsonEditorAt(params.lineNumber, params.column); + }, + { lineNumber, column } + ); +}