-
+
{t('JSON')}
+
+ {cursorText}
+
- {jsonStatus.valid ? (
-
+ {jsonStatus.isValid ? (
+
) : (
-
+
)}
- {jsonStatus.message}
+ {statusMessage}
+
-
-
-
- {lineNumbers.map((lineNumber) => (
-
{lineNumber}
- ))}
-
-
-
diff --git a/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts b/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts
new file mode 100644
index 00000000..bbac95fd
--- /dev/null
+++ b/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts
@@ -0,0 +1,100 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import assert from 'node:assert/strict'
+import { describe, test } from 'node:test'
+
+import {
+ applyJsonSmartEnter,
+ createScrollLayerSynchronizer,
+ formatJsonDraft,
+ getCursorLocation,
+ getJsonValidationState,
+} from '../json-code-editor-utils'
+
+describe('json code editor utils', () => {
+ test('treats empty drafts as valid editable JSON drafts', () => {
+ assert.deepEqual(getJsonValidationState(' \n'), {
+ isValid: true,
+ messageKey: 'JSON',
+ })
+ })
+
+ test('reports invalid JSON without throwing away the draft', () => {
+ assert.deepEqual(getJsonValidationState('{"model": }'), {
+ isValid: false,
+ messageKey: 'Invalid JSON',
+ })
+ })
+
+ test('formats valid JSON with stable two-space indentation', () => {
+ assert.deepEqual(formatJsonDraft('{"model":{"ratio":2}}'), {
+ didFormat: true,
+ value: '{\n "model": {\n "ratio": 2\n }\n}',
+ })
+ })
+
+ test('keeps invalid JSON drafts unchanged when formatting is requested', () => {
+ assert.deepEqual(formatJsonDraft('{"model": }'), {
+ didFormat: false,
+ value: '{"model": }',
+ })
+ })
+
+ test('derives the one-based cursor line and column from text offsets', () => {
+ assert.deepEqual(getCursorLocation('{\n "model": 1\n}', 5), {
+ line: 2,
+ column: 4,
+ })
+ })
+
+ test('expands paired JSON brackets with a nested indentation line', () => {
+ assert.deepEqual(applyJsonSmartEnter('{}', 1, 1), {
+ value: '{\n \n}',
+ selectionStart: 4,
+ selectionEnd: 4,
+ })
+ })
+
+ test('coalesces scroll updates while keeping line numbers horizontally fixed', () => {
+ const source = { scrollLeft: 12, scrollTop: 40 }
+ const contentLayer = { style: { transform: '' } }
+ const lineNumberLayer = { style: { transform: '' } }
+ const queuedFrames: Array<() => void> = []
+ const synchronizer = createScrollLayerSynchronizer(
+ source,
+ { contentLayer, lineNumberLayer },
+ (callback) => {
+ queuedFrames.push(callback)
+ return queuedFrames.length
+ }
+ )
+
+ synchronizer.sync()
+ source.scrollLeft = 24
+ source.scrollTop = 80
+ synchronizer.sync()
+
+ assert.equal(queuedFrames.length, 1)
+
+ queuedFrames[0]()
+
+ assert.equal(contentLayer.style.transform, 'translate3d(-24px, -80px, 0)')
+ assert.equal(lineNumberLayer.style.transform, 'translate3d(0, -80px, 0)')
+ })
+})
diff --git a/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx b/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx
new file mode 100644
index 00000000..24959b2c
--- /dev/null
+++ b/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx
@@ -0,0 +1,180 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import assert from 'node:assert/strict'
+import { after, describe, test } from 'node:test'
+
+import { Window } from 'happy-dom'
+
+const domWindow = new Window()
+const domGlobals = [
+ 'window',
+ 'document',
+ 'navigator',
+ 'HTMLElement',
+ 'HTMLTextAreaElement',
+ 'Node',
+ 'Element',
+ 'Event',
+ 'CustomEvent',
+ 'MutationObserver',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ 'getComputedStyle',
+] as const
+
+for (const key of domGlobals) {
+ Object.defineProperty(globalThis, key, {
+ configurable: true,
+ value: domWindow[key],
+ })
+}
+
+const { act } = await import('react')
+const { createRoot } = await import('react-dom/client')
+const i18next = (await import('i18next')).default
+const { initReactI18next } = await import('react-i18next')
+await i18next.use(initReactI18next).init({
+ lng: 'en',
+ resources: {
+ en: {
+ translation: {
+ JSON: 'JSON',
+ 'Invalid JSON': 'Invalid JSON',
+ 'Copied to clipboard': 'Copied to clipboard',
+ 'Failed to copy': 'Failed to copy',
+ 'Format JSON': 'Format JSON',
+ },
+ },
+ },
+})
+const { JsonCodeEditor } = await import('../../json-code-editor')
+const reactTestGlobals = globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean
+}
+reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
+
+type RenderedEditor = {
+ container: HTMLDivElement
+ root: ReturnType
+}
+
+async function renderEditor(
+ props: React.ComponentProps
+): Promise {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+
+ await act(async () => {
+ root.render()
+ })
+
+ return { container, root }
+}
+
+async function unmountEditor(rendered: RenderedEditor) {
+ await act(async () => rendered.root.unmount())
+ rendered.container.remove()
+}
+
+describe('JsonCodeEditor component', () => {
+ after(() => {
+ domWindow.close()
+ })
+
+ test('forwards form attributes and lifecycle callbacks to the textarea', async () => {
+ const blurCalls: number[] = []
+ const refValues: Array = []
+ const rendered = await renderEditor({
+ value: '{"model":"gpt"}',
+ onChange: () => undefined,
+ id: 'json-input',
+ name: 'model_config',
+ placeholder: '{"model":"gpt"}',
+ disabled: true,
+ 'aria-describedby': 'model-help',
+ 'aria-invalid': true,
+ 'data-form-root': 'settings-form',
+ onBlur: () => blurCalls.push(1),
+ textareaRef: (element) => refValues.push(element),
+ })
+ const textarea = rendered.container.querySelector('textarea')
+
+ assert.ok(textarea)
+ assert.equal(textarea.id, 'json-input')
+ assert.equal(textarea.name, 'model_config')
+ assert.equal(textarea.placeholder, '{"model":"gpt"}')
+ assert.equal(textarea.disabled, true)
+ assert.equal(textarea.getAttribute('aria-describedby'), 'model-help')
+ assert.equal(textarea.getAttribute('aria-invalid'), 'true')
+ assert.equal(textarea.getAttribute('data-form-root'), 'settings-form')
+
+ await act(async () => textarea.dispatchEvent(new Event('blur')))
+ assert.deepEqual(blurCalls, [1])
+ assert.equal(refValues[0], textarea)
+
+ await unmountEditor(rendered)
+ assert.equal(refValues.at(-1), null)
+ })
+
+ test('emits user edits and synchronizes a controlled value', async () => {
+ const changes: string[] = []
+ const rendered = await renderEditor({
+ value: '{"count":1}',
+ onChange: (value) => changes.push(value),
+ })
+ const textarea = rendered.container.querySelector('textarea')
+
+ assert.ok(textarea)
+ await act(async () => {
+ textarea.value = '{"count":2}'
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ assert.deepEqual(changes, ['{"count":2}'])
+
+ await act(async () => {
+ rendered.root.render(
+ changes.push(value)}
+ />
+ )
+ })
+ assert.equal(textarea.value, '{"count":3}')
+
+ await unmountEditor(rendered)
+ })
+
+ test('formats valid JSON through the public toolbar action', async () => {
+ const changes: string[] = []
+ const rendered = await renderEditor({
+ value: '{"model":{"ratio":2}}',
+ onChange: (value) => changes.push(value),
+ })
+ const formatButton = [
+ ...rendered.container.querySelectorAll('button'),
+ ].find((button) => button.textContent?.includes('Format JSON'))
+
+ assert.ok(formatButton)
+ await act(async () => formatButton.click())
+ assert.deepEqual(changes, ['{\n "model": {\n "ratio": 2\n }\n}'])
+
+ await unmountEditor(rendered)
+ })
+})
diff --git a/web/src/components/json-code-editor/json-code-editor-utils.ts b/web/src/components/json-code-editor/json-code-editor-utils.ts
new file mode 100644
index 00000000..c06eefa1
--- /dev/null
+++ b/web/src/components/json-code-editor/json-code-editor-utils.ts
@@ -0,0 +1,198 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import type { Plugin, TextareaProps } from 'yace'
+
+export type JsonValidationState = {
+ isValid: boolean
+ messageKey: 'JSON' | 'Invalid JSON'
+}
+
+export type JsonFormatResult = {
+ didFormat: boolean
+ value: string
+}
+
+export type CursorLocation = {
+ line: number
+ column: number
+}
+
+type ScrollSource = {
+ scrollLeft: number
+ scrollTop: number
+}
+
+type TransformLayer = {
+ style: Pick
+}
+
+type ScrollLayers = {
+ contentLayer: TransformLayer
+ lineNumberLayer: TransformLayer
+}
+
+type RequestFrame = (callback: () => void) => number
+
+export type ScrollLayerSynchronizer = {
+ sync: () => void
+}
+
+export function getJsonValidationState(value: string): JsonValidationState {
+ const trimmed = value.trim()
+ if (!trimmed) {
+ return { isValid: true, messageKey: 'JSON' }
+ }
+
+ try {
+ JSON.parse(trimmed)
+ return { isValid: true, messageKey: 'JSON' }
+ } catch {
+ return { isValid: false, messageKey: 'Invalid JSON' }
+ }
+}
+
+export function formatJsonDraft(value: string): JsonFormatResult {
+ const trimmed = value.trim()
+ if (!trimmed) {
+ return { didFormat: false, value }
+ }
+
+ try {
+ return {
+ didFormat: true,
+ value: JSON.stringify(JSON.parse(trimmed), null, 2),
+ }
+ } catch {
+ return { didFormat: false, value }
+ }
+}
+
+export function getCursorLocation(
+ value: string,
+ selectionStart: number
+): CursorLocation {
+ const boundedSelectionStart = Math.min(
+ Math.max(selectionStart, 0),
+ value.length
+ )
+ const linesBeforeCursor = value.slice(0, boundedSelectionStart).split('\n')
+ const currentLine = linesBeforeCursor.at(-1) ?? ''
+
+ return {
+ line: linesBeforeCursor.length,
+ column: currentLine.length + 1,
+ }
+}
+
+export function createScrollLayerSynchronizer(
+ source: ScrollSource,
+ layers: ScrollLayers,
+ requestFrame: RequestFrame = window.requestAnimationFrame
+): ScrollLayerSynchronizer {
+ let hasPendingFrame = false
+
+ return {
+ sync: () => {
+ if (hasPendingFrame) {
+ return
+ }
+
+ hasPendingFrame = true
+ requestFrame(() => {
+ hasPendingFrame = false
+ layers.contentLayer.style.transform = `translate3d(-${source.scrollLeft}px, -${source.scrollTop}px, 0)`
+ layers.lineNumberLayer.style.transform = `translate3d(0, -${source.scrollTop}px, 0)`
+ })
+ },
+ }
+}
+
+export function applyJsonSmartEnter(
+ value: string,
+ selectionStart: number,
+ selectionEnd: number
+): TextareaProps | undefined {
+ const before = value.slice(0, selectionStart)
+ const after = value.slice(selectionEnd)
+ const indent = getLineIndent(value, selectionStart)
+ const previousChar = before.trimEnd().at(-1)
+ const nextChar = after.trimStart().at(0)
+ const shouldNest = previousChar === '{' || previousChar === '['
+ const shouldClose =
+ (previousChar === '{' && nextChar === '}') ||
+ (previousChar === '[' && nextChar === ']')
+
+ if (shouldNest && shouldClose) {
+ const innerIndent = `${indent} `
+ const insert = `\n${innerIndent}\n${indent}`
+ const nextSelection = selectionStart + 1 + innerIndent.length
+
+ return {
+ value: `${before}${insert}${after}`,
+ selectionStart: nextSelection,
+ selectionEnd: nextSelection,
+ }
+ }
+
+ if (!indent && !shouldNest) {
+ return undefined
+ }
+
+ const nextIndent = shouldNest ? `${indent} ` : indent
+ const insert = `\n${nextIndent}`
+ const nextSelection = selectionStart + insert.length
+
+ return {
+ value: `${before}${insert}${after}`,
+ selectionStart: nextSelection,
+ selectionEnd: nextSelection,
+ }
+}
+
+export function jsonSmartEnter(): Plugin {
+ return (props, event) => {
+ if (event.type !== 'keydown') {
+ return undefined
+ }
+
+ const keyboardEvent = event as KeyboardEvent
+ if (keyboardEvent.key !== 'Enter') {
+ return undefined
+ }
+
+ const nextProps = applyJsonSmartEnter(
+ props.value,
+ props.selectionStart,
+ props.selectionEnd
+ )
+
+ if (!nextProps) {
+ return undefined
+ }
+
+ event.preventDefault()
+ return nextProps
+ }
+}
+
+function getLineIndent(value: string, cursor: number): string {
+ const lineStart = value.lastIndexOf('\n', cursor - 1) + 1
+
+ return value.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
+}
diff --git a/web/src/components/json-editor.tsx b/web/src/components/json-editor.tsx
index 034155f3..1de35761 100644
--- a/web/src/components/json-editor.tsx
+++ b/web/src/components/json-editor.tsx
@@ -20,10 +20,9 @@ import { Code, Table, Plus, Trash2 } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
+import { JsonCodeEditor } from '@/components/json-code-editor'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
-import { Textarea } from '@/components/ui/textarea'
-import { cn } from '@/lib/utils'
type JsonEditorProps = {
value: string
@@ -285,15 +284,14 @@ export function JsonEditor({
) : (
-