@tai42/studio-sdk 13.3.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/components/components.css +19 -0
  2. package/dist/components/icons.d.ts +2 -0
  3. package/dist/components/icons.d.ts.map +1 -1
  4. package/dist/components/icons.js +2 -0
  5. package/dist/components/icons.js.map +1 -1
  6. package/dist/components/templated-text-field.d.ts +98 -0
  7. package/dist/components/templated-text-field.d.ts.map +1 -0
  8. package/dist/components/templated-text-field.js +217 -0
  9. package/dist/components/templated-text-field.js.map +1 -0
  10. package/dist/hooks/useApi.d.ts +461 -59
  11. package/dist/hooks/useApi.d.ts.map +1 -1
  12. package/dist/index.d.ts +6 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/navigation/types.d.ts +2 -1
  17. package/dist/navigation/types.d.ts.map +1 -1
  18. package/dist/query-keys.d.ts +13 -0
  19. package/dist/query-keys.d.ts.map +1 -1
  20. package/dist/query-keys.js +13 -0
  21. package/dist/query-keys.js.map +1 -1
  22. package/dist/state-binding/AdapterMapping.d.ts +33 -0
  23. package/dist/state-binding/AdapterMapping.d.ts.map +1 -0
  24. package/dist/state-binding/AdapterMapping.js +205 -0
  25. package/dist/state-binding/AdapterMapping.js.map +1 -0
  26. package/dist/state-binding/BindingJqField.d.ts +43 -0
  27. package/dist/state-binding/BindingJqField.d.ts.map +1 -0
  28. package/dist/state-binding/BindingJqField.js +57 -0
  29. package/dist/state-binding/BindingJqField.js.map +1 -0
  30. package/dist/state-binding/BindingTemplatedJqField.d.ts +24 -0
  31. package/dist/state-binding/BindingTemplatedJqField.d.ts.map +1 -0
  32. package/dist/state-binding/BindingTemplatedJqField.js +7 -0
  33. package/dist/state-binding/BindingTemplatedJqField.js.map +1 -0
  34. package/dist/state-binding/InputInjectionRow.d.ts +18 -0
  35. package/dist/state-binding/InputInjectionRow.d.ts.map +1 -0
  36. package/dist/state-binding/InputInjectionRow.js +55 -0
  37. package/dist/state-binding/InputInjectionRow.js.map +1 -0
  38. package/dist/state-binding/StateAttachRow.d.ts +27 -0
  39. package/dist/state-binding/StateAttachRow.d.ts.map +1 -0
  40. package/dist/state-binding/StateAttachRow.js +102 -0
  41. package/dist/state-binding/StateAttachRow.js.map +1 -0
  42. package/dist/state-binding/StateBindingEditor.d.ts +37 -0
  43. package/dist/state-binding/StateBindingEditor.d.ts.map +1 -0
  44. package/dist/state-binding/StateBindingEditor.js +54 -0
  45. package/dist/state-binding/StateBindingEditor.js.map +1 -0
  46. package/dist/state-binding/StateBindingSection.d.ts +14 -0
  47. package/dist/state-binding/StateBindingSection.d.ts.map +1 -0
  48. package/dist/state-binding/StateBindingSection.js +17 -0
  49. package/dist/state-binding/StateBindingSection.js.map +1 -0
  50. package/dist/state-binding/SubjectScopeFields.d.ts +20 -0
  51. package/dist/state-binding/SubjectScopeFields.d.ts.map +1 -0
  52. package/dist/state-binding/SubjectScopeFields.js +8 -0
  53. package/dist/state-binding/SubjectScopeFields.js.map +1 -0
  54. package/dist/state-binding/UpdateRow.d.ts +20 -0
  55. package/dist/state-binding/UpdateRow.d.ts.map +1 -0
  56. package/dist/state-binding/UpdateRow.js +89 -0
  57. package/dist/state-binding/UpdateRow.js.map +1 -0
  58. package/dist/state-binding/adapter.d.ts +93 -0
  59. package/dist/state-binding/adapter.d.ts.map +1 -0
  60. package/dist/state-binding/adapter.js +251 -0
  61. package/dist/state-binding/adapter.js.map +1 -0
  62. package/dist/state-binding/build-catalog.d.ts +19 -0
  63. package/dist/state-binding/build-catalog.d.ts.map +1 -0
  64. package/dist/state-binding/build-catalog.js +43 -0
  65. package/dist/state-binding/build-catalog.js.map +1 -0
  66. package/dist/state-binding/catalog.d.ts +26 -0
  67. package/dist/state-binding/catalog.d.ts.map +1 -0
  68. package/dist/state-binding/catalog.js +71 -0
  69. package/dist/state-binding/catalog.js.map +1 -0
  70. package/dist/state-binding/index.d.ts +33 -0
  71. package/dist/state-binding/index.d.ts.map +1 -0
  72. package/dist/state-binding/index.js +20 -0
  73. package/dist/state-binding/index.js.map +1 -0
  74. package/dist/state-binding/templated-text-catalog.d.ts +21 -0
  75. package/dist/state-binding/templated-text-catalog.d.ts.map +1 -0
  76. package/dist/state-binding/templated-text-catalog.js +48 -0
  77. package/dist/state-binding/templated-text-catalog.js.map +1 -0
  78. package/dist/state-binding/types.d.ts +78 -0
  79. package/dist/state-binding/types.d.ts.map +1 -0
  80. package/dist/state-binding/types.js +2 -0
  81. package/dist/state-binding/types.js.map +1 -0
  82. package/package.json +5 -5
  83. package/src/components/components.css +19 -0
  84. package/src/components/icons.tsx +8 -0
  85. package/src/components/templated-text-field.tsx +508 -0
  86. package/src/index.ts +76 -1
  87. package/src/navigation/types.ts +4 -1
  88. package/src/query-keys.ts +15 -0
  89. package/src/state-binding/AdapterMapping.tsx +468 -0
  90. package/src/state-binding/BindingJqField.tsx +146 -0
  91. package/src/state-binding/BindingTemplatedJqField.tsx +73 -0
  92. package/src/state-binding/InputInjectionRow.tsx +157 -0
  93. package/src/state-binding/README.md +46 -0
  94. package/src/state-binding/StateAttachRow.tsx +289 -0
  95. package/src/state-binding/StateBindingEditor.tsx +166 -0
  96. package/src/state-binding/StateBindingSection.tsx +37 -0
  97. package/src/state-binding/SubjectScopeFields.tsx +57 -0
  98. package/src/state-binding/UpdateRow.tsx +227 -0
  99. package/src/state-binding/adapter.ts +276 -0
  100. package/src/state-binding/build-catalog.ts +59 -0
  101. package/src/state-binding/catalog.ts +87 -0
  102. package/src/state-binding/index.ts +67 -0
  103. package/src/state-binding/templated-text-catalog.ts +69 -0
  104. package/src/state-binding/types.ts +91 -0
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The adapter mapping form for a NAMED update. It renders one row per declared
3
+ * input key of the update jq; each row picks ONE of three value sources — a picked
4
+ * field (from the run's output / input schema, when a schema is known), a hardcoded
5
+ * literal, or a jq expression — and the whole form compiles into ONE adapter jq over
6
+ * `{ output, input }` that constructs the update jq's declared input object. Each
7
+ * row shows its own jq on demand, and the whole compiled adapter is shown on demand.
8
+ *
9
+ * A raw-jq escape hatch (`Write jq`) authors the adapter directly when the mapping needs
10
+ * more than the row form expresses. An update that declares NO input needs no adapter, so
11
+ * it shows a tool-output passthrough and stores `null` (the escape hatch stays available).
12
+ *
13
+ * The stored value always equals what the form shows (WYSIWYG): an accepted default is
14
+ * emitted on mount, so it is never left as the empty string the platform's save refuses.
15
+ * The emitted adapter is a non-empty jq or `null`, never `''`.
16
+ */
17
+ import { useEffect, useState, type ReactNode } from 'react';
18
+
19
+ import { Badge } from '../components/badge';
20
+ import { Button, Skeleton } from '../components/primitives';
21
+ import { Field } from '../components/field';
22
+ import { Select } from '../components/select';
23
+ import { TextInput } from '../components/inputs';
24
+ import { BindingJqField, type TemplateJqSuggestion } from './BindingJqField';
25
+ import {
26
+ compileAdapter,
27
+ parseAdapter,
28
+ rowValueJq,
29
+ type FieldRoot,
30
+ type MappingRow,
31
+ type MappingSource,
32
+ } from './adapter';
33
+ import type { BindingSourceSchemas, SchemaFieldPath } from './types';
34
+
35
+ const ROOT_OPTIONS: readonly { value: FieldRoot; label: string }[] = [
36
+ { value: 'output', label: 'Run output' },
37
+ { value: 'input', label: 'Run input' },
38
+ ];
39
+
40
+ const SOURCE_OPTIONS = [
41
+ { value: 'field', label: 'Field' },
42
+ { value: 'literal', label: 'Literal' },
43
+ { value: 'jq', label: 'Jq' },
44
+ ];
45
+
46
+ export interface AdapterMappingProps {
47
+ /** The declared input keys of the update jq — one row each. Empty ⇒ raw jq only. */
48
+ readonly declaredInput: readonly string[];
49
+ /** The current adapter jq (compiled or authored) stored on the update; `''` ⇒ none stored. */
50
+ readonly value: string;
51
+ /** Emits the adapter to store: a non-empty jq, or `null` for no adapter (never `''`). */
52
+ readonly onChange: (adapterJq: string | null) => void;
53
+ /** The output / input field paths a picker offers. */
54
+ readonly sources?: BindingSourceSchemas;
55
+ /** Template jq offered as `tjq_<name>({…})` inserts in the jq escape hatch. */
56
+ readonly suggestions?: readonly TemplateJqSuggestion[];
57
+ }
58
+
59
+ function fieldsFor(
60
+ root: FieldRoot,
61
+ sources: BindingSourceSchemas | undefined,
62
+ ): readonly SchemaFieldPath[] {
63
+ if (root === 'output') return sources?.output ?? [];
64
+ return sources?.input ?? [];
65
+ }
66
+
67
+ /** The value control for one row's picked-field source: a Select where a schema is known, else a path input. */
68
+ function FieldSourceControl({
69
+ root,
70
+ path,
71
+ onRootChange,
72
+ onPathChange,
73
+ fields,
74
+ loading = false,
75
+ error,
76
+ }: {
77
+ readonly root: FieldRoot;
78
+ readonly path: readonly string[];
79
+ readonly onRootChange: (root: FieldRoot) => void;
80
+ readonly onPathChange: (path: readonly string[]) => void;
81
+ readonly fields: readonly SchemaFieldPath[];
82
+ readonly loading?: boolean;
83
+ readonly error?: string;
84
+ }): ReactNode {
85
+ const pathInput = (
86
+ <TextInput
87
+ aria-label="Field path"
88
+ placeholder="path.into.value"
89
+ value={path.join('.')}
90
+ onChange={(event) => {
91
+ const text = event.target.value.trim();
92
+ onPathChange(text === '' ? [] : text.split('.'));
93
+ }}
94
+ />
95
+ );
96
+ return (
97
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-1)' }}>
98
+ <div style={{ display: 'flex', gap: 'var(--tai-space-2)', flexWrap: 'wrap' }}>
99
+ <Field label="Field root" hideLabel>
100
+ <Select
101
+ value={root}
102
+ onValueChange={(next) => {
103
+ onRootChange(next as FieldRoot);
104
+ }}
105
+ options={ROOT_OPTIONS.map((option) => ({ value: option.value, label: option.label }))}
106
+ />
107
+ </Field>
108
+ {loading ? (
109
+ <Skeleton height={32} width={220} />
110
+ ) : fields.length > 0 ? (
111
+ <Field label="Field" hideLabel>
112
+ <Select
113
+ placeholder="Choose a field"
114
+ value={path.length === 0 ? '' : JSON.stringify(path)}
115
+ onValueChange={(next) => {
116
+ onPathChange(next === '' ? [] : (JSON.parse(next) as string[]));
117
+ }}
118
+ options={[
119
+ { value: '', label: '(whole value)' },
120
+ ...fields.map((field) => ({
121
+ value: JSON.stringify(field.path),
122
+ label: field.label,
123
+ })),
124
+ ]}
125
+ />
126
+ </Field>
127
+ ) : (
128
+ pathInput
129
+ )}
130
+ </div>
131
+ {error !== undefined && !loading ? (
132
+ <span
133
+ role="status"
134
+ style={{ color: 'var(--tai-color-text-muted)', fontSize: 'var(--tai-text-sm)' }}
135
+ >
136
+ {error}
137
+ </span>
138
+ ) : null}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /** One row per declared input key, each defaulting to the whole run output. */
144
+ function defaultRows(declaredInput: readonly string[]): MappingRow[] {
145
+ return declaredInput.map((target): MappingRow => ({
146
+ target,
147
+ source: { kind: 'field', root: 'output', path: [] },
148
+ }));
149
+ }
150
+
151
+ function MappingRowEditor({
152
+ row,
153
+ onChange,
154
+ sources,
155
+ suggestions,
156
+ }: {
157
+ readonly row: MappingRow;
158
+ readonly onChange: (row: MappingRow) => void;
159
+ readonly sources?: BindingSourceSchemas;
160
+ readonly suggestions?: readonly TemplateJqSuggestion[];
161
+ }): ReactNode {
162
+ const [showJq, setShowJq] = useState(false);
163
+ const source = row.source;
164
+ const preview = rowValueJq(source);
165
+
166
+ const setSource = (next: MappingSource): void => {
167
+ onChange({ ...row, source: next });
168
+ };
169
+
170
+ const changeKind = (kind: string): void => {
171
+ if (kind === 'field') setSource({ kind: 'field', root: 'output', path: [] });
172
+ else if (kind === 'literal') setSource({ kind: 'literal', json: '' });
173
+ else setSource({ kind: 'jq', expr: '' });
174
+ };
175
+
176
+ return (
177
+ <div
178
+ data-testid={`adapter-row-${row.target}`}
179
+ style={{
180
+ display: 'flex',
181
+ flexDirection: 'column',
182
+ gap: 'var(--tai-space-1)',
183
+ padding: 'var(--tai-space-2)',
184
+ border: '1px solid var(--tai-color-border)',
185
+ borderRadius: 'var(--tai-radius-md)',
186
+ }}
187
+ >
188
+ <div
189
+ style={{
190
+ display: 'flex',
191
+ alignItems: 'center',
192
+ gap: 'var(--tai-space-2)',
193
+ flexWrap: 'wrap',
194
+ }}
195
+ >
196
+ <Badge variant="neutral">{row.target}</Badge>
197
+ <Field label={`Source for ${row.target}`} hideLabel>
198
+ <Select
199
+ aria-label={`Source for ${row.target}`}
200
+ value={source.kind}
201
+ onValueChange={changeKind}
202
+ options={SOURCE_OPTIONS}
203
+ />
204
+ </Field>
205
+ </div>
206
+
207
+ {source.kind === 'field' ? (
208
+ <FieldSourceControl
209
+ root={source.root}
210
+ path={source.path}
211
+ fields={fieldsFor(source.root, sources)}
212
+ loading={sources?.loading}
213
+ error={sources?.error}
214
+ onRootChange={(nextRoot) => {
215
+ setSource({ kind: 'field', root: nextRoot, path: [] });
216
+ }}
217
+ onPathChange={(nextPath) => {
218
+ setSource({ kind: 'field', root: source.root, path: nextPath });
219
+ }}
220
+ />
221
+ ) : source.kind === 'literal' ? (
222
+ <Field
223
+ label={`Literal for ${row.target}`}
224
+ hideLabel
225
+ error={preview.ok ? undefined : preview.error}
226
+ >
227
+ <TextInput
228
+ aria-label={`Literal for ${row.target}`}
229
+ placeholder='"a string", 42, true, or {"k":1}'
230
+ value={source.json}
231
+ onChange={(event) => {
232
+ setSource({ kind: 'literal', json: event.target.value });
233
+ }}
234
+ />
235
+ </Field>
236
+ ) : (
237
+ <BindingJqField
238
+ label={`Jq for ${row.target}`}
239
+ value={source.expr}
240
+ onChange={(next) => {
241
+ setSource({ kind: 'jq', expr: next });
242
+ }}
243
+ error={preview.ok ? undefined : preview.error}
244
+ suggestions={suggestions}
245
+ />
246
+ )}
247
+
248
+ <div>
249
+ <Button
250
+ type="button"
251
+ variant="ghost"
252
+ aria-expanded={showJq}
253
+ onClick={() => {
254
+ setShowJq((open) => !open);
255
+ }}
256
+ >
257
+ {showJq ? 'Hide jq' : 'Show jq'}
258
+ </Button>
259
+ {showJq ? (
260
+ <pre
261
+ data-testid={`adapter-row-jq-${row.target}`}
262
+ style={{ margin: 0, fontFamily: 'var(--tai-font-mono)', whiteSpace: 'pre-wrap' }}
263
+ >
264
+ {preview.ok ? preview.jq : preview.error}
265
+ </pre>
266
+ ) : null}
267
+ </div>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ export function AdapterMapping({
273
+ declaredInput,
274
+ value,
275
+ onChange,
276
+ sources,
277
+ suggestions,
278
+ }: AdapterMappingProps): ReactNode {
279
+ const canMap = declaredInput.length > 0;
280
+ // Rebuild the form from STORED data: a stored adapter that parses back to rows reopens
281
+ // as the mapping form; anything else opens in the raw-jq escape hatch. With no declared
282
+ // input the update needs no adapter, so it opens on the tool-output passthrough (a
283
+ // stored jq still reopens the escape hatch).
284
+ const recovered = value.trim() === '' ? null : parseAdapter(value);
285
+ const [mode, setMode] = useState<'fields' | 'jq'>(() => {
286
+ if (value.trim() === '') return 'fields';
287
+ return canMap && recovered !== null ? 'fields' : 'jq';
288
+ });
289
+ const [rows, setRows] = useState<MappingRow[]>(() => recovered ?? defaultRows(declaredInput));
290
+ // Set when a toggle to fields mode finds a jq the row form cannot represent: the
291
+ // note explains why the view stayed on the raw jq (see `toFields`).
292
+ const [unmappable, setUnmappable] = useState(false);
293
+
294
+ const emit = (nextRows: readonly MappingRow[]): void => {
295
+ // WYSIWYG: store exactly what the form shows. A compile failure has no valid adapter,
296
+ // so emit `null` (no adapter) — never the empty string the platform's save refuses.
297
+ const compiled = compileAdapter(nextRows);
298
+ onChange(compiled.ok ? compiled.jq : null);
299
+ };
300
+
301
+ // WYSIWYG on mount: with no stored value the form shows a default — the compiled
302
+ // declared-input rows when mappable, else the tool-output passthrough — so store it AT
303
+ // ONCE. An operator who accepts the shown default must not leave an empty adapter the
304
+ // platform's save-time compile check refuses. Re-runs when the declared input changes
305
+ // the shown default; the guard preserves a stored/authored value untouched.
306
+ const declaredKey = declaredInput.join('');
307
+ useEffect(() => {
308
+ if (mode !== 'fields' || value.trim() !== '') return;
309
+ const seeded = canMap ? compileAdapter(defaultRows(declaredInput)) : null;
310
+ onChange(seeded?.ok ? seeded.jq : null);
311
+ // eslint-disable-next-line react-hooks/exhaustive-deps
312
+ }, [declaredKey]);
313
+
314
+ // Toggle to the row form by re-deriving rows from the CURRENT stored jq, never from
315
+ // the rows held since mount (those go stale while the user edits the raw jq, so
316
+ // compiling them would silently overwrite the authored jq). A non-empty jq the row
317
+ // form cannot represent keeps the raw view and surfaces a note instead of clobbering
318
+ // it; a clean round-trip re-emits nothing, so an authored jq is preserved byte-for-byte.
319
+ // With no declared input there is nothing to map — the toggle returns to the passthrough.
320
+ const toFields = (): void => {
321
+ if (!canMap) {
322
+ setMode('fields');
323
+ setUnmappable(false);
324
+ onChange(null);
325
+ return;
326
+ }
327
+ const parsed = value.trim() === '' ? null : parseAdapter(value);
328
+ if (value.trim() !== '' && parsed === null) {
329
+ setUnmappable(true);
330
+ return;
331
+ }
332
+ const nextRows = parsed ?? defaultRows(declaredInput);
333
+ setRows(nextRows);
334
+ setMode('fields');
335
+ setUnmappable(false);
336
+ const compiled = compileAdapter(nextRows);
337
+ if (compiled.ok && compiled.jq !== value) onChange(compiled.jq);
338
+ };
339
+
340
+ // The raw-jq edit path clears a stale unmappable note so a re-mapping attempt sees the
341
+ // edited jq, then forwards the edit — normalizing an empty/whitespace box to `null` (no
342
+ // adapter) so the empty string is never stored.
343
+ const onJqChange = (next: string): void => {
344
+ if (unmappable) setUnmappable(false);
345
+ onChange(next.trim() === '' ? null : next);
346
+ };
347
+
348
+ const setRow = (index: number, next: MappingRow): void => {
349
+ const nextRows = rows.map((row, position) => (position === index ? next : row));
350
+ setRows(nextRows);
351
+ emit(nextRows);
352
+ };
353
+
354
+ const compiled = compileAdapter(rows);
355
+ const [showCompiled, setShowCompiled] = useState(false);
356
+
357
+ // An adapter is refused empty ONLY when the update declares inputs to fill; a
358
+ // no-declared-input update legitimately stores no adapter (the passthrough).
359
+ const rawError =
360
+ canMap && mode === 'jq' && value.trim() === ''
361
+ ? 'Map the declared inputs or write an adapter jq — an empty adapter is refused.'
362
+ : undefined;
363
+
364
+ return (
365
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
366
+ <div style={{ display: 'flex', gap: 'var(--tai-space-2)' }}>
367
+ <Button
368
+ type="button"
369
+ variant={mode === 'fields' ? 'primary' : 'ghost'}
370
+ aria-pressed={mode === 'fields'}
371
+ onClick={toFields}
372
+ >
373
+ {canMap ? 'Map fields' : 'Use tool output'}
374
+ </Button>
375
+ <Button
376
+ type="button"
377
+ variant={mode === 'jq' ? 'primary' : 'ghost'}
378
+ aria-pressed={mode === 'jq'}
379
+ onClick={() => {
380
+ // Seed the escape hatch with the compiled adapter so nothing is lost — only
381
+ // when there are rows to compile; a no-declared-input update opens empty.
382
+ if (canMap && value.trim() === '' && compiled.ok) onChange(compiled.jq);
383
+ setMode('jq');
384
+ }}
385
+ >
386
+ Write jq
387
+ </Button>
388
+ </div>
389
+
390
+ {unmappable ? (
391
+ <p
392
+ role="status"
393
+ style={{
394
+ margin: 0,
395
+ color: 'var(--tai-color-text-muted)',
396
+ fontSize: 'var(--tai-text-sm)',
397
+ }}
398
+ >
399
+ This adapter jq cannot be shown as fields. Edit it here.
400
+ </p>
401
+ ) : null}
402
+
403
+ {mode === 'fields' ? (
404
+ canMap ? (
405
+ <>
406
+ {rows.map((row, index) => (
407
+ <MappingRowEditor
408
+ key={row.target}
409
+ row={row}
410
+ sources={sources}
411
+ suggestions={suggestions}
412
+ onChange={(next) => {
413
+ setRow(index, next);
414
+ }}
415
+ />
416
+ ))}
417
+ {!compiled.ok ? (
418
+ <p role="alert" style={{ margin: 0, color: 'var(--tai-color-err-text)' }}>
419
+ {compiled.error}
420
+ </p>
421
+ ) : null}
422
+ <div>
423
+ <Button
424
+ type="button"
425
+ variant="ghost"
426
+ aria-expanded={showCompiled}
427
+ onClick={() => {
428
+ setShowCompiled((open) => !open);
429
+ }}
430
+ >
431
+ {showCompiled ? 'Hide adapter jq' : 'Show adapter jq'}
432
+ </Button>
433
+ {showCompiled ? (
434
+ <pre
435
+ data-testid="adapter-compiled-jq"
436
+ style={{ margin: 0, fontFamily: 'var(--tai-font-mono)', whiteSpace: 'pre-wrap' }}
437
+ >
438
+ {compiled.ok ? compiled.jq : compiled.error}
439
+ </pre>
440
+ ) : null}
441
+ </div>
442
+ </>
443
+ ) : (
444
+ <p
445
+ role="status"
446
+ style={{
447
+ margin: 0,
448
+ color: 'var(--tai-color-text-muted)',
449
+ fontSize: 'var(--tai-text-sm)',
450
+ }}
451
+ >
452
+ This update runs with the tool output as its input.
453
+ </p>
454
+ )
455
+ ) : (
456
+ <BindingJqField
457
+ label="Adapter"
458
+ description="Maps the run's output into what this update expects as `.input`."
459
+ value={value}
460
+ onChange={onJqChange}
461
+ suggestions={suggestions}
462
+ placeholder="{ total: .output.total }"
463
+ error={rawError}
464
+ />
465
+ )}
466
+ </div>
467
+ );
468
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The jq input every field a binding owns renders through — the Subject/Scope
3
+ * expressions, a custom injection or update jq, an adapter escape hatch, an op-id.
4
+ *
5
+ * It reuses the form door pattern: when a host has injected an expression editor
6
+ * through {@link ExpressionFieldContext} (the visual jq editor from
7
+ * `@tai42/jq-studio`), that door renders here; with no door it falls back to a
8
+ * plain multiline textarea, so this package keeps NO edge to the jq subgraph.
9
+ *
10
+ * The attached templates' template jq are offered as an autocomplete source: an
11
+ * `Insert` control per jq name appends the call `tjq_<name>({…})` to the
12
+ * expression — the attachment-qualified `tjq_<attachment>__<name>` form when a name is
13
+ * ambiguous across attachments.
14
+ */
15
+ import { useContext, type ReactNode } from 'react';
16
+
17
+ import { Button } from '../components/primitives';
18
+ import { PlusIcon } from '../components/icons';
19
+ import { Field } from '../components/field';
20
+ import { Textarea } from '../components/inputs';
21
+ import { ExpressionFieldContext } from '../schema-form/context';
22
+ import { generateTemplateCall } from './adapter';
23
+
24
+ /** One template jq offered as an insertable autocomplete entry. */
25
+ export interface TemplateJqSuggestion {
26
+ /** The jq reference, e.g. `active` or the attachment-qualified `tally.bump`. */
27
+ readonly ref: string;
28
+ readonly purpose: 'input' | 'update';
29
+ readonly description?: string;
30
+ }
31
+
32
+ export interface BindingJqFieldProps {
33
+ readonly label: string;
34
+ readonly value: string;
35
+ readonly onChange: (value: string) => void;
36
+ readonly description?: ReactNode;
37
+ readonly error?: string;
38
+ readonly placeholder?: string;
39
+ /** Template jq offered as `tjq_<name>({…})` inserts under the field. */
40
+ readonly suggestions?: readonly TemplateJqSuggestion[];
41
+ /**
42
+ * Render the field's own visible label visually hidden while keeping it as the
43
+ * control's accessible name — for a field mounted under a group header that
44
+ * already shows the label (a `TemplatedTextField` slot). Off by default, so a
45
+ * bare use (a mapping row's jq) keeps its visible label.
46
+ */
47
+ readonly hideLabel?: boolean;
48
+ }
49
+
50
+ /** Append a `tjq_<name>({…})` template call to the current expression (space-separated when non-empty). */
51
+ export function appendTjq(current: string, ref: string): string {
52
+ const call = generateTemplateCall(ref, []);
53
+ const trimmed = current.trim();
54
+ return trimmed === '' ? call : `${trimmed} ${call}`;
55
+ }
56
+
57
+ export function BindingJqField({
58
+ label,
59
+ value,
60
+ onChange,
61
+ description,
62
+ error,
63
+ placeholder,
64
+ suggestions = [],
65
+ hideLabel = false,
66
+ }: BindingJqFieldProps): ReactNode {
67
+ const Door = useContext(ExpressionFieldContext);
68
+ // A stable hook for e2e: the injected jq editor owns its own DOM, so the wrapper
69
+ // carries the field's identity (`binding-jq-<label>`) for the driver to locate.
70
+ const testId = `binding-jq-${label.trim().replace(/\s+/g, '-').toLowerCase()}`;
71
+ // Only `input`-purpose jq are callable from an expression; an `update` jq returns an
72
+ // op batch and is picked solely by an update's own select — never offered as an insert.
73
+ const inputSuggestions = suggestions.filter((suggestion) => suggestion.purpose === 'input');
74
+
75
+ return (
76
+ <div
77
+ data-testid={testId}
78
+ // The label-hide is scoped to a direct-child jq editor: the injected door is a
79
+ // direct child here, so a group header above it becomes the single visible label.
80
+ className={hideLabel ? 'tai-templated-inline--grouped' : undefined}
81
+ style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-1)' }}
82
+ >
83
+ {Door !== undefined ? (
84
+ <Door
85
+ label={label}
86
+ value={value}
87
+ onChange={onChange}
88
+ description={description}
89
+ error={error}
90
+ multiline
91
+ />
92
+ ) : (
93
+ <Field
94
+ label={label}
95
+ hideLabel={hideLabel}
96
+ description={typeof description === 'string' ? description : undefined}
97
+ error={error}
98
+ >
99
+ <Textarea
100
+ value={value}
101
+ placeholder={placeholder}
102
+ onChange={(event) => {
103
+ onChange(event.target.value);
104
+ }}
105
+ aria-invalid={error !== undefined ? true : undefined}
106
+ />
107
+ </Field>
108
+ )}
109
+ {inputSuggestions.length > 0 ? (
110
+ <div
111
+ role="group"
112
+ aria-label={`Insert template jq into ${label}`}
113
+ style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--tai-space-1)' }}
114
+ >
115
+ {inputSuggestions.map((suggestion) => (
116
+ <Button
117
+ key={suggestion.ref}
118
+ type="button"
119
+ variant="secondary"
120
+ // A compact outlined pill: the leading plus reads it as an insert
121
+ // affordance so the monospace call text is a control, not stray code.
122
+ style={{
123
+ minHeight: 'auto',
124
+ padding: 'var(--tai-space-1) var(--tai-space-2)',
125
+ borderRadius: 'var(--tai-radius-full)',
126
+ fontFamily: 'var(--tai-font-mono)',
127
+ fontSize: 'var(--tai-text-sm)',
128
+ }}
129
+ title={
130
+ suggestion.description !== undefined && suggestion.description !== ''
131
+ ? `${suggestion.purpose} — ${suggestion.description}`
132
+ : suggestion.purpose
133
+ }
134
+ onClick={() => {
135
+ onChange(appendTjq(value, suggestion.ref));
136
+ }}
137
+ >
138
+ <PlusIcon />
139
+ {generateTemplateCall(suggestion.ref, [])}
140
+ </Button>
141
+ ))}
142
+ </div>
143
+ ) : null}
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * One state-binding jq slot authored as a {@link TemplatedText}: a
3
+ * `TemplatedTextField` whose INLINE editor is the jq field (the host's visual jq
4
+ * door, else a textarea) carrying the attached templates' template-jq inserts, and
5
+ * whose STORED mode picks a stored template that renders to the jq at run time.
6
+ */
7
+ import type { ReactNode } from 'react';
8
+
9
+ import { TemplatedTextField } from '../components/templated-text-field';
10
+ import { BindingJqField, type TemplateJqSuggestion } from './BindingJqField';
11
+ import type { TemplatedText, TemplatedTextCatalog } from './types';
12
+
13
+ export interface BindingTemplatedJqFieldProps {
14
+ readonly label: string;
15
+ readonly value: TemplatedText | null;
16
+ readonly onChange: (value: TemplatedText | null) => void;
17
+ readonly description?: string;
18
+ readonly error?: string;
19
+ /** The subject/adapter slots require a source; the rest are optional (`null` clears). */
20
+ readonly required?: boolean;
21
+ readonly placeholder?: string;
22
+ readonly suggestions?: readonly TemplateJqSuggestion[];
23
+ readonly templates?: TemplatedTextCatalog;
24
+ readonly disabled?: boolean;
25
+ }
26
+
27
+ export function BindingTemplatedJqField({
28
+ label,
29
+ value,
30
+ onChange,
31
+ description,
32
+ error,
33
+ required,
34
+ placeholder,
35
+ suggestions,
36
+ templates,
37
+ disabled,
38
+ }: BindingTemplatedJqFieldProps): ReactNode {
39
+ return (
40
+ <TemplatedTextField
41
+ label={label}
42
+ value={value}
43
+ onChange={onChange}
44
+ description={description}
45
+ error={error}
46
+ required={required}
47
+ disabled={disabled}
48
+ templates={templates?.templates}
49
+ templatesLoading={templates?.loading}
50
+ templatesError={templates?.error}
51
+ onTemplatesRetry={templates?.onRetry}
52
+ storageAbsent={templates?.storageAbsent}
53
+ storagePresenceLoading={templates?.storagePresenceLoading}
54
+ renderInline={({
55
+ label: inlineLabel,
56
+ value: inlineValue,
57
+ onChange: onInlineChange,
58
+ error: inlineError,
59
+ hideLabel: inlineHideLabel,
60
+ }) => (
61
+ <BindingJqField
62
+ label={inlineLabel}
63
+ value={inlineValue}
64
+ onChange={onInlineChange}
65
+ error={inlineError}
66
+ placeholder={placeholder}
67
+ suggestions={suggestions}
68
+ hideLabel={inlineHideLabel}
69
+ />
70
+ )}
71
+ />
72
+ );
73
+ }