From 64de6eaf29b9ef1d7b5768bf2e12d837d7f30858 Mon Sep 17 00:00:00 2001 From: siddhant-galileo Date: Thu, 2 Apr 2026 11:28:15 +0530 Subject: [PATCH 1/6] feat: --- ui/package.json | 9 + ui/pnpm-lock.yaml | 271 ++- .../json-editor-codemirror/index.ts | 1 + .../json-editor-codemirror-language.ts | 14 + .../json-editor-codemirror.tsx | 398 ++++ .../language/auto-edits.ts | 256 +++ .../language/context.ts | 124 ++ .../language/extensions.ts | 885 ++++++++ .../json-editor-codemirror/language/format.ts | 32 + .../json-editor-codemirror/language/index.ts | 10 + .../json-editor-codemirror/language/schema.ts | 168 ++ .../json-editor-codemirror/language/types.ts | 40 + ui/src/components/json-editor-monaco/index.ts | 1 + .../json-editor-monaco-language.ts | 1940 +++++++++++++++++ .../json-editor-monaco/json-editor-monaco.tsx | 6 + .../edit-control/edit-control-content.tsx | 93 +- .../modals/edit-control/json-editor-view.tsx | 12 +- 17 files changed, 4198 insertions(+), 62 deletions(-) create mode 100644 ui/src/components/json-editor-codemirror/index.ts create mode 100644 ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts create mode 100644 ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx create mode 100644 ui/src/components/json-editor-codemirror/language/auto-edits.ts create mode 100644 ui/src/components/json-editor-codemirror/language/context.ts create mode 100644 ui/src/components/json-editor-codemirror/language/extensions.ts create mode 100644 ui/src/components/json-editor-codemirror/language/format.ts create mode 100644 ui/src/components/json-editor-codemirror/language/index.ts create mode 100644 ui/src/components/json-editor-codemirror/language/schema.ts create mode 100644 ui/src/components/json-editor-codemirror/language/types.ts create mode 100644 ui/src/components/json-editor-monaco/index.ts create mode 100644 ui/src/components/json-editor-monaco/json-editor-monaco-language.ts create mode 100644 ui/src/components/json-editor-monaco/json-editor-monaco.tsx diff --git a/ui/package.json b/ui/package.json index 1d8748ac..8fd01666 100644 --- a/ui/package.json +++ b/ui/package.json @@ -39,7 +39,13 @@ "prettify:check": "Check formatting with Prettier without changing files" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/lang-json": "^6.0.2", + "@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 +62,9 @@ "@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/react-codemirror": "^4.25.9", "axios": "1.12.0", "classix": "2.2.0", "date-fns": "4.1.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 49f92450..d4b2b952 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -8,9 +8,27 @@ importers: .: dependencies: + '@codemirror/autocomplete': + specifier: ^6.20.1 + version: 6.20.1 + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 + '@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 +77,15 @@ 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/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 @@ -228,6 +255,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==} @@ -348,105 +402,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -487,6 +525,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 +609,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==} @@ -595,28 +648,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.4.8': resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.4.8': resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.4.8': resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.4.8': resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==} @@ -730,28 +779,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -924,6 +969,35 @@ 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-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] @@ -963,49 +1037,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1202,6 +1268,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 +1297,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'} @@ -2031,28 +2103,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -2700,6 +2768,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 +2957,9 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + 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'} @@ -3040,6 +3114,64 @@ snapshots: '@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 @@ -3260,6 +3392,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 +3486,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 @@ -3689,6 +3839,39 @@ 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-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 @@ -3945,6 +4128,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 +4160,8 @@ snapshots: convert-source-map@2.0.0: {} + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5601,6 +5796,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 +6008,8 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + 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/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..cf0e35dc --- /dev/null +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts @@ -0,0 +1,14 @@ +export { + applyTextEdit, + buildCodeMirrorJsonExtensions, + buildCodeMirrorRefactorLightbulbExtension, + buildCodeMirrorStandaloneDebugExtensions, + computeAutoEdit, + extractEvaluatorNames, + fixJsonCommas, + getCodeMirrorCompletionItems, + normalizeOnBlur, + shouldTriggerEvaluatorNameCompletion, + triggerRefactorActionsDropdown, + tryFormat, +} from './language'; 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..ca5b18f6 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx @@ -0,0 +1,398 @@ +import { json, jsonParseLinter } from '@codemirror/lang-json'; +import { type Diagnostic, linter, lintGutter } from '@codemirror/lint'; +import { type Extension } from '@codemirror/state'; +import { EditorView, type ViewUpdate } from '@codemirror/view'; +import { ActionIcon, Box, Group, Text, Tooltip } from '@mantine/core'; +import { useClipboard } from '@mantine/hooks'; +import { + IconClipboardCheck, + IconClipboardCopy, + IconCode, +} from '@tabler/icons-react'; +import createTheme from '@uiw/codemirror-themes'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +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 { + buildCodeMirrorJsonExtensions, + buildCodeMirrorStandaloneDebugExtensions, + computeAutoEdit, + extractEvaluatorNames, +} from './json-editor-codemirror-language'; + +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 theme = createTheme({ + theme: 'light', + settings: { + background: 'var(--mantine-color-body)', + foreground: 'var(--mantine-color-text)', + caret: 'var(--mantine-color-text)', + gutterBackground: 'var(--mantine-color-body)', + gutterBorder: 'var(--mantine-color-body)', + gutterForeground: 'var(--mantine-color-dimmed)', + }, + styles: [], +}); + +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', + }, +}); + +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, + 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 [isDarkMode] = useState(false); + 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(() => { + // const detect = () => + // setIsDarkMode( + // document.documentElement.getAttribute('data-mantine-color-scheme') === + // 'dark' + // ); + // detect(); + // const obs = new MutationObserver(detect); + // obs.observe(document.documentElement, { + // attributes: true, + // attributeFilter: ['data-mantine-color-scheme'], + // }); + // return () => obs.disconnect(); + // }, []); + + 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 => { + try { + return ( + (JSON.parse(text) as { action?: { decision?: string } })?.action + ?.decision ?? null + ); + } catch { + return 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) { + autoEditInProgressRef.current = false; + 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; + view.dispatch({ + changes: { + from: edit.offset, + to: edit.offset + edit.length, + insert: edit.newText, + }, + }); + + const nextText = view.state.doc.toString(); + previousEvaluatorNamesRef.current = extractEvaluatorNames(nextText); + previousDecisionRef.current = parseDecision(nextText); + internalChangeRef.current = true; + handleJsonChange(nextText); + }, + [ + editorMode, + evaluators, + handleJsonChange, + parseDecision, + effectiveDebugFlags.enableAutoEdits, + ] + ); + + const extensions = useMemo( + () => [ + json(), + ...(effectiveDebugFlags.enableLintExtensions + ? [linter(jsonParseLinter()), lintGutter()] + : []), + DENSITY_THEME, + ...domainExtensions, + EditorView.updateListener.of(handleAutoEdits), + ], + [domainExtensions, effectiveDebugFlags.enableLintExtensions, handleAutoEdits] + ); + + const onEditorChange = useCallback( + (value: string) => { + internalChangeRef.current = true; + handleJsonChange(value); + }, + [handleJsonChange] + ); + + // 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)); + }, []); + + useEffect(() => { + if (!validationError && lintErrors.length === 0) return; + }, [lintErrors, validationError]); + + return ( + + + + + + + + + + + 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 ? ( + + + + ) : 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..927f495a --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/auto-edits.ts @@ -0,0 +1,256 @@ +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 = null; + try { + nextDecision = + (JSON.parse(text) as { action?: { decision?: string } })?.action + ?.decision ?? 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..6bef170a --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/context.ts @@ -0,0 +1,124 @@ +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; +} + +export function resolveSchemaAtJsonPath( + context: JsonEditorCodeMirrorContext, + activeEvaluator: JsonEditorEvaluatorOption | null, + path: JsonPath +): SchemaCursor { + let rootSchema = asSchema(context.schema) ?? null; + 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 (const segment of path) { + if (cursor === null) break; + 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..ff63a86a --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/extensions.ts @@ -0,0 +1,885 @@ +import { + acceptCompletion, + autocompletion, + type Completion, + completionKeymap, + moveCompletionSelection, + snippetCompletion, + startCompletion, +} from '@codemirror/autocomplete'; +import { + EditorSelection, + type Extension, + Prec, + type Range, + RangeSetBuilder, +} from '@codemirror/state'; +import { + Decoration, + type EditorView, + gutter, + GutterMarker, + hoverTooltip, + keymap, + ViewPlugin, + WidgetType, +} from '@codemirror/view'; +import { + findNodeAtLocation, + getLocation, + type Node as JsonNode, + parseTree, +} from 'jsonc-parser'; + +import { + getEnumValues, + getScopeFilters, + isEvaluatorNameLocation, + isSelectorPathLocation, + parseJsonTree, + resolveActiveEvaluator, + resolveSchemaAtJsonPath, +} from './context'; +import { + getSchemaAtProperty, + getSchemaDescription, + getSchemaProperties, + getSchemaTitle, + getSchemaType, + normalizeSchema, +} from './schema'; +import { + type JsonEditorCodeMirrorContext, + MAX_HINT_VALUES, + ROOT_SELECTOR_PATHS, +} from './types'; + +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); +} + +function getPropertySuggestions( + text: string, + context: JsonEditorCodeMirrorContext, + path: Array, + offset: number +): 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 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'; + let defaultValue = '""'; + if (type === 'number' || type === 'integer') defaultValue = '0'; + if (type === 'boolean') defaultValue = 'false'; + if (type === 'array') defaultValue = '[]'; + if (type === 'object') defaultValue = '{}'; + suggestions.push({ + ...snippetCompletion(`"${propertyName}": ${defaultValue}`, { + label: propertyName, + type: 'property', + detail: type, + }), + 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({ changes: { from, to, insert } }); + }, + })); + } + + 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: isStringValueContext ? item.label : JSON.stringify(item.label), + })) + ); + } + + 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({ changes: { from, to, insert } }); + }, + })); +} + +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), + })); +} + +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 { + 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; +} + +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?: { + enableHoverAndHintsExtensions?: boolean; + } +): Extension[] { + const enableHoverAndHintsExtensions = + options?.enableHoverAndHintsExtensions ?? true; + + 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 = isStringValueContext + ? { + from: valueNode.offset + 1, + to: valueNode.offset + Math.max(valueNode.length - 1, 1), + } + : { from: completionContext.pos, to: completionContext.pos }; + + 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) { + return { + from: refactorContext.from, + to: refactorContext.to, + filter: false, + options: _toRefactorCompletions(refactorContext.actions), + }; + } + } + + const options = dedupeCompletions( + location.isAtPropertyKey + ? getPropertySuggestions( + text, + context, + location.path, + completionContext.pos + ) + : getValueSuggestions( + text, + context, + location.path, + isStringValueContext + ) + ); + + if (options.length === 0) { + return null; + } + + return { + from: range.from, + to: range.to, + filter: false, + options, + }; + }, + ], + }), + completionNavigationKeymap, + keymap.of(completionKeymap), + buildCodeMirrorRefactorLightbulbExtension(context), + ...(enableHoverAndHintsExtensions + ? [_createHoverExtension(context), _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 = isStringValueContext + ? { + from: valueNode.offset + 1, + to: valueNode.offset + Math.max(valueNode.length - 1, 1), + } + : { from: completionContext.pos, to: completionContext.pos }; + + if (location.isAtPropertyKey) { + return { + from: range.from, + to: range.to, + options: rootKeys.map((key) => ({ + label: key, + type: 'property', + apply: `"${key}"`, + })), + }; + } + + 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: false, + options: values.map((value) => ({ + label: value, + type: 'enum', + info: `Enum value: ${value}`, + apply: isStringValueContext ? value : JSON.stringify(value), + })), + }; + }, + ], + }), + 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 = line.from; + const refactorContext = getRefactorContext(text, offset, context.mode); + if (!refactorContext) return false; + view.dispatch({ + selection: EditorSelection.single(offset), + scrollIntoView: true, + }); + 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) + : 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..b57a747f --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/format.ts @@ -0,0 +1,32 @@ +import { type ParseError, parseTree } from 'jsonc-parser'; + +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..f9905592 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/index.ts @@ -0,0 +1,10 @@ +export { applyTextEdit, computeAutoEdit, extractEvaluatorNames } from './auto-edits'; +export { + buildCodeMirrorJsonExtensions, + buildCodeMirrorRefactorLightbulbExtension, + buildCodeMirrorStandaloneDebugExtensions, + getCodeMirrorCompletionItems, + shouldTriggerEvaluatorNameCompletion, + triggerRefactorActionsDropdown, +} from './extensions'; +export { fixJsonCommas, normalizeOnBlur, tryFormat } from './format'; 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..ec646e55 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/language/schema.ts @@ -0,0 +1,168 @@ +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; +} 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..934ceed5 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,19 @@ 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 +788,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 +821,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; From 1f7caeb3bd21763686efbc7a05d3ee9053eecdf3 Mon Sep 17 00:00:00 2001 From: siddhant-galileo Date: Thu, 2 Apr 2026 11:58:25 +0530 Subject: [PATCH 2/6] feat: add theme selector dropdown --- ui/package.json | 2 + ui/pnpm-lock.yaml | 473 ++++++++++++++++++ .../codemirror-theme-presets.ts | 150 ++++++ .../json-editor-codemirror.tsx | 142 ++++-- 4 files changed, 733 insertions(+), 34 deletions(-) create mode 100644 ui/src/components/json-editor-codemirror/codemirror-theme-presets.ts diff --git a/ui/package.json b/ui/package.json index 8fd01666..b7448b1e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -41,6 +41,7 @@ "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", @@ -64,6 +65,7 @@ "@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", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index d4b2b952..de1a7b00 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@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 @@ -83,6 +86,9 @@ importers: '@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) @@ -402,89 +408,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -648,24 +670,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.4.8': resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.4.8': resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.4.8': resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.4.8': resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==} @@ -779,24 +805,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -980,6 +1010,114 @@ packages: '@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: @@ -1037,41 +1175,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2103,24 +2249,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -3849,6 +3999,329 @@ snapshots: '@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 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..bcb0ed18 --- /dev/null +++ b/ui/src/components/json-editor-codemirror/codemirror-theme-presets.ts @@ -0,0 +1,150 @@ +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 = + { + [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 = + { + [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/json-editor-codemirror.tsx b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx index ca5b18f6..3951b1a0 100644 --- a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx @@ -2,14 +2,21 @@ import { json, jsonParseLinter } from '@codemirror/lang-json'; import { type Diagnostic, linter, lintGutter } from '@codemirror/lint'; import { type Extension } from '@codemirror/state'; import { EditorView, type ViewUpdate } from '@codemirror/view'; -import { ActionIcon, Box, Group, Text, Tooltip } from '@mantine/core'; +import { + ActionIcon, + Box, + Group, + NativeSelect, + Text, + Tooltip, + useMantineColorScheme, +} from '@mantine/core'; import { useClipboard } from '@mantine/hooks'; import { IconClipboardCheck, IconClipboardCopy, IconCode, } from '@tabler/icons-react'; -import createTheme from '@uiw/codemirror-themes'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ProblemDetail, StepSchema } from '@/core/api/types'; @@ -21,6 +28,16 @@ import type { 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 { buildCodeMirrorJsonExtensions, buildCodeMirrorStandaloneDebugExtensions, @@ -46,19 +63,6 @@ const DEFAULT_LABEL = 'Configuration (JSON)'; const DEFAULT_TOOLTIP = 'Raw JSON configuration'; const DEFAULT_TEST_ID = 'raw-json-textarea'; -const theme = createTheme({ - theme: 'light', - settings: { - background: 'var(--mantine-color-body)', - foreground: 'var(--mantine-color-text)', - caret: 'var(--mantine-color-text)', - gutterBackground: 'var(--mantine-color-body)', - gutterBorder: 'var(--mantine-color-body)', - gutterForeground: 'var(--mantine-color-dimmed)', - }, - styles: [], -}); - const DENSITY_THEME = EditorView.theme({ '&': { fontSize: '12px', @@ -127,7 +131,11 @@ export function JsonEditorCodeMirror({ }: JsonEditorCodeMirrorProps) { const [CodeMirrorComponent, setCodeMirrorComponent] = useState(null); - const [isDarkMode] = useState(false); + 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); @@ -155,20 +163,27 @@ export function JsonEditorCodeMirror({ void loadModules(); }, []); - // useEffect(() => { - // const detect = () => - // setIsDarkMode( - // document.documentElement.getAttribute('data-mantine-color-scheme') === - // 'dark' - // ); - // detect(); - // const obs = new MutationObserver(detect); - // obs.observe(document.documentElement, { - // attributes: true, - // attributeFilter: ['data-mantine-color-scheme'], - // }); - // return () => obs.disconnect(); - // }, []); + useEffect(() => { + setCmThemePrefs((prev) => { + const darkOk = + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_DARK_THEME_PRESETS, + prev.dark + ); + const lightOk = + Object.prototype.hasOwnProperty.call( + CODE_MIRROR_LIGHT_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) { @@ -264,7 +279,11 @@ export function JsonEditorCodeMirror({ ...domainExtensions, EditorView.updateListener.of(handleAutoEdits), ], - [domainExtensions, effectiveDebugFlags.enableLintExtensions, handleAutoEdits] + [ + domainExtensions, + effectiveDebugFlags.enableLintExtensions, + handleAutoEdits, + ] ); const onEditorChange = useCallback( @@ -301,11 +320,65 @@ export function JsonEditorCodeMirror({ if (!validationError && lintErrors.length === 0) return; }, [lintErrors, validationError]); + const codeMirrorTheme = useMemo(() => { + if (isDarkMode) { + return ( + CODE_MIRROR_DARK_THEME_PRESETS[cmThemePrefs.dark]?.extension ?? + CODE_MIRROR_DARK_THEME_PRESETS[DEFAULT_DARK_THEME_ID].extension + ); + } + return ( + CODE_MIRROR_LIGHT_THEME_PRESETS[cmThemePrefs.light]?.extension ?? + mantineLightCodeMirrorTheme + ); + }, [isDarkMode, cmThemePrefs.dark, cmThemePrefs.light]); + + const cmThemeSelectData = useMemo( + () => + Object.entries( + isDarkMode + ? CODE_MIRROR_DARK_THEME_PRESETS + : CODE_MIRROR_LIGHT_THEME_PRESETS + ).map(([value, { label: optionLabel }]) => ({ + value, + label: optionLabel, + })), + [isDarkMode] + ); + + const cmThemeSelectValue = useMemo(() => { + const raw = isDarkMode ? cmThemePrefs.dark : cmThemePrefs.light; + const presets = isDarkMode + ? CODE_MIRROR_DARK_THEME_PRESETS + : CODE_MIRROR_LIGHT_THEME_PRESETS; + if (raw in presets) 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; + }); + }} + /> + + @@ -346,7 +420,7 @@ export function JsonEditorCodeMirror({ effectiveDebugFlags.enableLintExtensions ? handleLint : undefined } extensions={extensions} - theme={isDarkMode ? 'dark' : theme} + theme={codeMirrorTheme} basicSetup={ effectiveDebugFlags.enableBasicSetupExtension ? { From ac60e2a1789af4782742bfa83c551603063d8191 Mon Sep 17 00:00:00 2001 From: siddhant-galileo Date: Thu, 2 Apr 2026 12:41:20 +0530 Subject: [PATCH 3/6] chore: minor fixes --- .../json-editor-codemirror.tsx | 153 ++++++++++++++---- .../language/auto-edits.ts | 10 +- .../language/extensions.ts | 27 ++-- 3 files changed, 147 insertions(+), 43 deletions(-) diff --git a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx index 3951b1a0..df19c5de 100644 --- a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx @@ -11,14 +11,16 @@ import { Tooltip, useMantineColorScheme, } from '@mantine/core'; -import { useClipboard } from '@mantine/hooks'; +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'; @@ -43,6 +45,8 @@ import { buildCodeMirrorStandaloneDebugExtensions, computeAutoEdit, extractEvaluatorNames, + fixJsonCommas, + tryFormat, } from './json-editor-codemirror-language'; type JsonEditorTestElement = HTMLDivElement & { @@ -62,6 +66,7 @@ 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({ '&': { @@ -117,6 +122,11 @@ export function JsonEditorCodeMirror({ handleJsonChange, jsonError, validationError, + onValidateConfig, + onValidationStatusChange, + setJsonError, + setValidationError, + validateDebounceMs, height = DEFAULT_HEIGHT, label = DEFAULT_LABEL, tooltip = DEFAULT_TOOLTIP, @@ -169,11 +179,19 @@ export function JsonEditorCodeMirror({ 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 = { @@ -206,14 +224,10 @@ export function JsonEditorCodeMirror({ ]); const parseDecision = useCallback((text: string): string | null => { - try { - return ( - (JSON.parse(text) as { action?: { decision?: string } })?.action - ?.decision ?? null - ); - } catch { - return null; - } + const tree = parseTree(text); + if (!tree) return null; + const node = findNodeAtLocation(tree, ['action', 'decision']); + return typeof node?.value === 'string' ? node.value : null; }, []); useEffect(() => { @@ -286,6 +300,67 @@ export function JsonEditorCodeMirror({ ] ); + 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); + setValidationError?.(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; @@ -294,6 +369,23 @@ export function JsonEditorCodeMirror({ [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; @@ -321,37 +413,39 @@ export function JsonEditorCodeMirror({ }, [lintErrors, validationError]); const codeMirrorTheme = useMemo(() => { - if (isDarkMode) { - return ( - CODE_MIRROR_DARK_THEME_PRESETS[cmThemePrefs.dark]?.extension ?? - CODE_MIRROR_DARK_THEME_PRESETS[DEFAULT_DARK_THEME_ID].extension - ); - } - return ( - CODE_MIRROR_LIGHT_THEME_PRESETS[cmThemePrefs.light]?.extension ?? - mantineLightCodeMirrorTheme - ); + 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( - isDarkMode - ? CODE_MIRROR_DARK_THEME_PRESETS - : CODE_MIRROR_LIGHT_THEME_PRESETS - ).map(([value, { label: optionLabel }]) => ({ + [ + ...Object.entries(CODE_MIRROR_DARK_THEME_PRESETS), + ...Object.entries(CODE_MIRROR_LIGHT_THEME_PRESETS), + ].map(([value, { label: optionLabel }]) => ({ value, label: optionLabel, })), - [isDarkMode] + [] ); const cmThemeSelectValue = useMemo(() => { const raw = isDarkMode ? cmThemePrefs.dark : cmThemePrefs.light; - const presets = isDarkMode - ? CODE_MIRROR_DARK_THEME_PRESETS - : CODE_MIRROR_LIGHT_THEME_PRESETS; - if (raw in presets) return raw; + 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]); @@ -385,6 +479,7 @@ export function JsonEditorCodeMirror({ color="gray" size="sm" aria-label="Format document" + onClick={formatJson} > diff --git a/ui/src/components/json-editor-codemirror/language/auto-edits.ts b/ui/src/components/json-editor-codemirror/language/auto-edits.ts index 927f495a..5b72ac76 100644 --- a/ui/src/components/json-editor-codemirror/language/auto-edits.ts +++ b/ui/src/components/json-editor-codemirror/language/auto-edits.ts @@ -207,11 +207,13 @@ export function computeAutoEdit( nextDecision: string | null; } { const nextEvaluatorNames = extractEvaluatorNames(text); - let nextDecision: string | null = null; + let nextDecision: string | null = previousDecision; try { - nextDecision = - (JSON.parse(text) as { action?: { decision?: string } })?.action - ?.decision ?? null; + const tree = parseTree(text); + if (tree) { + const node = findNodeAtLocation(tree, ['action', 'decision']); + nextDecision = typeof node?.value === 'string' ? node.value : null; + } } catch { nextDecision = previousDecision; } diff --git a/ui/src/components/json-editor-codemirror/language/extensions.ts b/ui/src/components/json-editor-codemirror/language/extensions.ts index ff63a86a..5b0fa4a4 100644 --- a/ui/src/components/json-editor-codemirror/language/extensions.ts +++ b/ui/src/components/json-editor-codemirror/language/extensions.ts @@ -8,7 +8,6 @@ import { startCompletion, } from '@codemirror/autocomplete'; import { - EditorSelection, type Extension, Prec, type Range, @@ -673,9 +672,13 @@ export function buildCodeMirrorJsonExtensions( 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: refactorContext.from, - to: refactorContext.to, + from: anchor, + to: anchor, filter: false, options: _toRefactorCompletions(refactorContext.actions), }; @@ -702,10 +705,17 @@ export function buildCodeMirrorJsonExtensions( return null; } + // CodeMirror's built-in filtering can re-rank completions while the user is + // actively typing. For enums with exactly 2 options (where we previously hit + // a keyboard-only inversion bug), we keep filtering disabled to preserve + // correct insertion. For 3+ options we allow filtering so the dropdown + // narrows as expected. + const filter = options.length <= 2 ? false : true; + return { from: range.from, to: range.to, - filter: false, + filter, options, }; }, @@ -825,15 +835,12 @@ export function buildCodeMirrorRefactorLightbulbExtension( return builder.finish(); }, domEventHandlers: { - mousedown(view, line) { + mousedown(view, _line) { const text = view.state.doc.toString(); - const offset = line.from; + const offset = view.state.selection.main.head; const refactorContext = getRefactorContext(text, offset, context.mode); if (!refactorContext) return false; - view.dispatch({ - selection: EditorSelection.single(offset), - scrollIntoView: true, - }); + // Don't move the caret (keep bulb aligned with the user's caret line). window.setTimeout(() => { triggerRefactorActionsDropdown(view, context.mode); }, 0); From e7c6fa8aa3003a22e289a6faee7ab3ae2dd4abc2 Mon Sep 17 00:00:00 2001 From: siddhant-galileo Date: Thu, 2 Apr 2026 13:24:32 +0530 Subject: [PATCH 4/6] feat: error rendering initial setup --- .../json-editor-codemirror-language.ts | 2 + .../json-editor-codemirror.tsx | 40 +++- .../language/extensions.ts | 7 +- .../json-editor-codemirror/language/index.ts | 2 + .../language/inline-server-validation.ts | 217 ++++++++++++++++++ 5 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 ui/src/components/json-editor-codemirror/language/inline-server-validation.ts 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 index cf0e35dc..d26db34d 100644 --- a/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts @@ -1,5 +1,6 @@ export { applyTextEdit, + buildCodeMirrorInlineServerValidationErrorsExtension, buildCodeMirrorJsonExtensions, buildCodeMirrorRefactorLightbulbExtension, buildCodeMirrorStandaloneDebugExtensions, @@ -8,6 +9,7 @@ export { fixJsonCommas, getCodeMirrorCompletionItems, normalizeOnBlur, + setInlineServerValidationErrorsEffect, shouldTriggerEvaluatorNameCompletion, triggerRefactorActionsDropdown, tryFormat, diff --git a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx index df19c5de..b5e9e1c9 100644 --- a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx @@ -41,11 +41,13 @@ import { writeStoredCodeMirrorThemePrefs, } from './codemirror-theme-presets'; import { + buildCodeMirrorInlineServerValidationErrorsExtension, buildCodeMirrorJsonExtensions, buildCodeMirrorStandaloneDebugExtensions, computeAutoEdit, extractEvaluatorNames, fixJsonCommas, + setInlineServerValidationErrorsEffect, tryFormat, } from './json-editor-codemirror-language'; @@ -283,6 +285,11 @@ export function JsonEditorCodeMirror({ ] ); + const inlineServerValidationExtension = useMemo( + () => buildCodeMirrorInlineServerValidationErrorsExtension(), + [] + ); + const extensions = useMemo( () => [ json(), @@ -292,11 +299,13 @@ export function JsonEditorCodeMirror({ DENSITY_THEME, ...domainExtensions, EditorView.updateListener.of(handleAutoEdits), + inlineServerValidationExtension, ], [ domainExtensions, effectiveDebugFlags.enableLintExtensions, handleAutoEdits, + inlineServerValidationExtension, ] ); @@ -331,7 +340,6 @@ export function JsonEditorCodeMirror({ validationAbortControllerRef.current = controller; setJsonError?.(null); - setValidationError?.(null); onValidationStatusChange?.('validating'); onValidateConfig(parsed, { signal: controller.signal }) @@ -408,10 +416,29 @@ export function JsonEditorCodeMirror({ 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 = @@ -551,9 +578,14 @@ export function JsonEditorCodeMirror({ ) : null} {validationError ? ( - - - + unmappedValidationErrors.length > 0 ? ( + + + + ) : null ) : null} (); + +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, + } + ), + ]; +} + From 9995d14f9cc68970d73aa006b84001459864461a Mon Sep 17 00:00:00 2001 From: siddhant-galileo Date: Thu, 2 Apr 2026 17:08:31 +0530 Subject: [PATCH 5/6] chore: add backend support for evaluator name check --- .../endpoints/controls.py | 20 ++++++++++++- server/tests/test_controls_validation.py | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/server/src/agent_control_server/endpoints/controls.py b/server/src/agent_control_server/endpoints/controls.py index 4fa44dbb..fbb3adc9 100644 --- a/server/src/agent_control_server/endpoints/controls.py +++ b/server/src/agent_control_server/endpoints/controls.py @@ -212,7 +212,25 @@ async def _validate_control_definition( evaluator_cls = available_evaluators.get(parsed.name) if evaluator_cls is None: - continue + available = list(available_evaluators.keys()) + raise APIValidationError( + error_code=ErrorCode.EVALUATOR_NOT_FOUND, + detail=f"Evaluator '{parsed.name}' is not registered", + resource="Evaluator", + hint=( + f"Check evaluator '{evaluator_ref}'. " + f"Available evaluators: {available or 'none'}." + ), + errors=[ + ValidationErrorItem( + resource="Control", + field=f"{field_prefix}.evaluator.name", + code="evaluator_not_found", + message=f"Evaluator '{parsed.name}' not found", + value=evaluator_ref, + ) + ], + ) try: evaluator_cls.config_model(**evaluator_spec.config) diff --git a/server/tests/test_controls_validation.py b/server/tests/test_controls_validation.py index 2761bdd3..070374ed 100644 --- a/server/tests/test_controls_validation.py +++ b/server/tests/test_controls_validation.py @@ -318,3 +318,33 @@ 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, +): + """Standalone evaluator failures should identify the exact nested leaf path.""" + control_id = create_control(client) + payload = deepcopy(VALID_CONTROL_PAYLOAD) + payload["condition"] = { + "or": [ + { + "selector": {"path": "input"}, + "evaluator": { + "name": "missing-evaluator", + "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"] == "EVALUATOR_NOT_FOUND" + assert any( + err.get("field") == "data.condition.or[0].evaluator.name" + and err.get("code") == "evaluator_not_found" + for err in body.get("errors", []) + ) From 7fe3877769047def0c53221e998885f6a316dcc2 Mon Sep 17 00:00:00 2001 From: siddhant-galileo Date: Thu, 2 Apr 2026 17:08:46 +0530 Subject: [PATCH 6/6] chore: formatting fixes --- .../json-editor-codemirror-language.ts | 1 + .../json-editor-codemirror.tsx | 58 ++++-- .../language/extensions.ts | 192 +++++++++++++++--- .../json-editor-codemirror/language/format.ts | 58 +++++- .../json-editor-codemirror/language/index.ts | 7 +- 5 files changed, 277 insertions(+), 39 deletions(-) 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 index d26db34d..cdb9faf6 100644 --- a/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror-language.ts @@ -4,6 +4,7 @@ export { buildCodeMirrorJsonExtensions, buildCodeMirrorRefactorLightbulbExtension, buildCodeMirrorStandaloneDebugExtensions, + caretAfterPrettyJsonReplace, computeAutoEdit, extractEvaluatorNames, fixJsonCommas, diff --git a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx index b5e9e1c9..4997140b 100644 --- a/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx +++ b/ui/src/components/json-editor-codemirror/json-editor-codemirror.tsx @@ -1,6 +1,7 @@ +import { closeCompletion } from '@codemirror/autocomplete'; import { json, jsonParseLinter } from '@codemirror/lang-json'; import { type Diagnostic, linter, lintGutter } from '@codemirror/lint'; -import { type Extension } from '@codemirror/state'; +import { EditorSelection, type Extension } from '@codemirror/state'; import { EditorView, type ViewUpdate } from '@codemirror/view'; import { ActionIcon, @@ -44,6 +45,7 @@ import { buildCodeMirrorInlineServerValidationErrorsExtension, buildCodeMirrorJsonExtensions, buildCodeMirrorStandaloneDebugExtensions, + caretAfterPrettyJsonReplace, computeAutoEdit, extractEvaluatorNames, fixJsonCommas, @@ -242,7 +244,6 @@ export function JsonEditorCodeMirror({ if (!effectiveDebugFlags.enableAutoEdits) return; if (!update.docChanged) return; if (autoEditInProgressRef.current) { - autoEditInProgressRef.current = false; return; } @@ -262,19 +263,48 @@ export function JsonEditorCodeMirror({ if (!edit) return; autoEditInProgressRef.current = true; - view.dispatch({ - changes: { - from: edit.offset, - to: edit.offset + edit.length, - insert: edit.newText, - }, - }); + 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(); + } - const nextText = view.state.doc.toString(); - previousEvaluatorNamesRef.current = extractEvaluatorNames(nextText); - previousDecisionRef.current = parseDecision(nextText); - internalChangeRef.current = true; - handleJsonChange(nextText); + previousEvaluatorNamesRef.current = extractEvaluatorNames(nextText); + previousDecisionRef.current = parseDecision(nextText); + internalChangeRef.current = true; + handleJsonChange(nextText); + } finally { + autoEditInProgressRef.current = false; + } }, [ editorMode, diff --git a/ui/src/components/json-editor-codemirror/language/extensions.ts b/ui/src/components/json-editor-codemirror/language/extensions.ts index 845362da..74748e00 100644 --- a/ui/src/components/json-editor-codemirror/language/extensions.ts +++ b/ui/src/components/json-editor-codemirror/language/extensions.ts @@ -1,9 +1,12 @@ import { acceptCompletion, autocompletion, + closeCompletion, type Completion, completionKeymap, + insertCompletionText, moveCompletionSelection, + pickedCompletion, snippetCompletion, startCompletion, } from '@codemirror/autocomplete'; @@ -21,6 +24,7 @@ import { hoverTooltip, keymap, ViewPlugin, + type ViewUpdate, WidgetType, } from '@codemirror/view'; import { @@ -157,12 +161,16 @@ function getValueSuggestions( info: item.description ?? undefined, apply: ( view: EditorView, - _completion: Completion, + completion: Completion, from: number, to: number ) => { const insert = isStringValueContext ? item.id : JSON.stringify(item.id); - view.dispatch({ changes: { from, to, insert } }); + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); + closeCompletion(view); }, })); } @@ -196,7 +204,20 @@ function getValueSuggestions( type: 'variable' as const, detail: item.detail, info: item.detail, - apply: isStringValueContext ? item.label : JSON.stringify(item.label), + 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), + }); + }, })) ); } @@ -211,7 +232,7 @@ function getValueSuggestions( info: typeof value === 'string' ? `Enum value: ${value}` : 'Enum value', apply: ( view: EditorView, - _completion: Completion, + completion: Completion, from: number, to: number ) => { @@ -219,7 +240,10 @@ function getValueSuggestions( isStringValueContext && typeof value === 'string' ? value : toJsonLiteral(value); - view.dispatch({ changes: { from, to, insert } }); + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); }, })); } @@ -432,7 +456,10 @@ function _toRefactorCompletions(actions: RefactorAction[]): Completion[] { return actions.map((action) => ({ label: action.label, type: 'method', - apply: (view) => action.apply(view), + apply: (view) => { + action.apply(view); + closeCompletion(view); + }, })); } @@ -494,6 +521,23 @@ function getHintForPath( 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 @@ -515,6 +559,87 @@ function getHintForPath( 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 { @@ -641,11 +766,13 @@ const completionNavigationKeymap = Prec.highest( export function buildCodeMirrorJsonExtensions( context: JsonEditorCodeMirrorContext, options?: { - enableHoverAndHintsExtensions?: boolean; + enableHoverExtension?: boolean; + enableHintsExtension?: boolean; } ): Extension[] { - const enableHoverAndHintsExtensions = - options?.enableHoverAndHintsExtensions ?? true; + const enableHoverExtension = options?.enableHoverExtension ?? true; + // Hints are intentionally off by default — dropdown completions cover the UX. + const enableHintsExtension = options?.enableHintsExtension ?? false; return [ autocompletion({ @@ -710,17 +837,10 @@ export function buildCodeMirrorJsonExtensions( return null; } - // CodeMirror's built-in filtering can re-rank completions while the user is - // actively typing. For enums with exactly 2 options (where we previously hit - // a keyboard-only inversion bug), we keep filtering disabled to preserve - // correct insertion. For 3+ options we allow filtering so the dropdown - // narrows as expected. - const filter = options.length <= 2 ? false : true; - return { from: range.from, to: range.to, - filter, + filter: true, options, }; }, @@ -728,10 +848,12 @@ export function buildCodeMirrorJsonExtensions( }), 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), - ...(enableHoverAndHintsExtensions - ? [_createHoverExtension(context), _createHintsExtension(context)] - : []), + ...(enableHoverExtension ? [_createHoverExtension(context)] : []), + ...(enableHintsExtension ? [_createHintsExtension(context)] : []), ]; } @@ -764,7 +886,18 @@ export function buildCodeMirrorStandaloneDebugExtensions(): Extension[] { options: rootKeys.map((key) => ({ label: key, type: 'property', - apply: `"${key}"`, + apply: ( + view: EditorView, + completion: Completion, + from: number, + to: number + ) => { + const insert = `"${key}"`; + view.dispatch({ + ...insertCompletionText(view.state, insert, from, to), + annotations: pickedCompletion.of(completion), + }); + }, })), }; } @@ -792,12 +925,25 @@ export function buildCodeMirrorStandaloneDebugExtensions(): Extension[] { return { from: range.from, to: range.to, - filter: false, + filter: true, options: values.map((value) => ({ label: value, type: 'enum', info: `Enum value: ${value}`, - apply: isStringValueContext ? value : JSON.stringify(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), + }); + }, })), }; }, diff --git a/ui/src/components/json-editor-codemirror/language/format.ts b/ui/src/components/json-editor-codemirror/language/format.ts index b57a747f..c1879bd0 100644 --- a/ui/src/components/json-editor-codemirror/language/format.ts +++ b/ui/src/components/json-editor-codemirror/language/format.ts @@ -1,4 +1,60 @@ -import { type ParseError, parseTree } from 'jsonc-parser'; +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 { diff --git a/ui/src/components/json-editor-codemirror/language/index.ts b/ui/src/components/json-editor-codemirror/language/index.ts index 74c25a9c..97c6a070 100644 --- a/ui/src/components/json-editor-codemirror/language/index.ts +++ b/ui/src/components/json-editor-codemirror/language/index.ts @@ -9,4 +9,9 @@ export { shouldTriggerEvaluatorNameCompletion, triggerRefactorActionsDropdown, } from './extensions'; -export { fixJsonCommas, normalizeOnBlur, tryFormat } from './format'; +export { + caretAfterPrettyJsonReplace, + fixJsonCommas, + normalizeOnBlur, + tryFormat, +} from './format';