@adecore/intelligent-ui 0.0.0 → 0.21.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A streaming compiler and bounded expression interpreter for UI blocks in agent replies. The catalog defines props once with Zod. Renderers consume evaluated props and input bindings; they do not evaluate model text.
4
4
 
5
- This package is private while its first publication is being prepared. Chat wire integration, daemon scheduling, host actions, query authorization and platform renderers are separate work. These APIs do not authorize a query or an action.
5
+ Chat wire integration and scheduling are provided by `@adecore/agents`; React renderers are provided by `@adecore/agents-react`. Hosts supply actions, query authorization and platform renderers. The compiler and interpreter do not authorize a query or an action.
6
6
 
7
7
  ```ts
8
8
  import { compileUi, evaluateUiBlock, UiState } from '@adecore/intelligent-ui';
@@ -29,7 +29,7 @@ The parser recovers at the next tag or line after malformed input. Open tags rem
29
29
 
30
30
  ## Catalog
31
31
 
32
- `UI_CATALOG` contains each component's schema, description, group and applicable parent, child or binding rule. `UI_GROUPS` contains the instructions shared by each group. `isUiComponent` recognizes a catalog name; `uiCatalogText` lists names, props and descriptions for tooling. `uiCompactCatalog` derives the short prop list from those schemas, `uiSessionNote` adds the language instructions, and `uiReferenceText` combines full descriptions with group rules. `uiFallbackText` replaces UI fences with their compiled fallback while retaining surrounding prose.
32
+ `UI_CATALOG` contains each component's schema, description, group and applicable parent, child or binding rule. `UI_GROUPS` contains the instructions shared by each group. `isUiComponent` recognizes a catalog name; `uiCatalogText` lists names, props and descriptions for tooling. `uiCompactCatalog` derives the short prop list from those schemas, `uiSessionNote` adds explicit tag syntax and a fenced example checked by the compiler, and `uiReferenceText` combines full descriptions with group rules. `uiFallbackText` replaces UI fences with their compiled fallback while retaining surrounding prose.
33
33
 
34
34
  `UiComponentName` is the key union. `UiProps<Name>` is inferred from that component's schema. `UiToneSchema` and `UiTone` provide neutral, info, success, warning and danger. `UI_CATALOG_VERSION` identifies this catalog.
35
35
 
@@ -3,6 +3,12 @@ import { type UiExpression, type UiValue } from './expression.ts';
3
3
  import { type UiDiagnostic } from './syntax.ts';
4
4
  import { type z } from 'zod';
5
5
  export declare const UI_FENCE_LANGUAGE = "ruimte-ui";
6
+ export declare const UI_REPLY_LIMITS: {
7
+ readonly blocks: 16;
8
+ readonly characters: 262144;
9
+ readonly nodes: 2048;
10
+ readonly milliseconds: 60;
11
+ };
6
12
  export interface UiNode {
7
13
  id: string;
8
14
  type: string;
@@ -19,10 +25,12 @@ export interface UiNode {
19
25
  export interface UiQuery {
20
26
  source: string;
21
27
  args: Record<string, UiValue>;
28
+ expression?: UiExpression;
22
29
  }
23
30
  export interface UiBlock {
24
31
  id: string;
25
32
  catalogVersion: number;
33
+ revision?: string;
26
34
  start: number;
27
35
  end: number;
28
36
  complete: boolean;
@@ -41,4 +49,13 @@ export interface UiCompileOptions {
41
49
  now?: () => number;
42
50
  }
43
51
  export declare function compileUiBlock(source: string, options: UiCompileOptions): UiBlock;
52
+ export type UiCompilerOptions = Omit<UiCompileOptions, 'final' | 'latestAttachment'>;
53
+ export type UiCompileUpdate = Pick<UiCompileOptions, 'final' | 'latestAttachment'>;
54
+ export declare class UiCompiler {
55
+ private readonly options;
56
+ private cache;
57
+ constructor(options: UiCompilerOptions);
58
+ compile(text: string, update?: UiCompileUpdate): UiBlock[];
59
+ clear(): void;
60
+ }
44
61
  export declare function compileUi(text: string, options: UiCompileOptions): UiBlock[];
package/dist/compiler.js CHANGED
@@ -4,6 +4,7 @@ import { evaluateUiExpression } from './expression.js';
4
4
  import { parseUiSyntax, uiDiagnostic } from './syntax.js';
5
5
  import {} from 'zod';
6
6
  export const UI_FENCE_LANGUAGE = 'ruimte-ui';
7
+ export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 };
7
8
  function isConstant(expression) {
8
9
  switch (expression.kind) {
9
10
  case 'literal':
@@ -178,14 +179,27 @@ export function compileUiBlock(source, options) {
178
179
  try {
179
180
  const syntax = parseUiSyntax(source, options.id, options.final, budget);
180
181
  block.diagnostics = syntax.diagnostics;
181
- for (const [name, expression] of Object.entries(syntax.declarations)) {
182
+ for (const [name, expression] of Object.entries(syntax.declarations).sort(([, a], [, b]) => Number(a.kind === 'call' && a.name === 'Query') - Number(b.kind === 'call' && b.name === 'Query'))) {
182
183
  try {
183
184
  if (expression.kind === 'call' && expression.name === 'Query') {
184
- if (expression.args.length !== 2 || !expression.args.every(isConstant)) {
185
- throw new UiFailure('invalid_query', '@Query needs a literal source and literal argument record.');
185
+ if (expression.args.length !== 2 || !isConstant(expression.args[0]) || expression.args[1].kind !== 'object') {
186
+ throw new UiFailure('invalid_query', '@Query needs a literal source and an argument record.');
186
187
  }
187
188
  const querySource = evaluateUiExpression(expression.args[0], {}, budget);
188
- const args = evaluateUiExpression(expression.args[1], {}, budget);
189
+ const argument = expression.args[1];
190
+ const references = (part) => {
191
+ if (!part || typeof part !== 'object') {
192
+ return;
193
+ }
194
+ if ('kind' in part && part.kind === 'reference' && 'name' in part && !Object.hasOwn(block.defaults, String(part.name))) {
195
+ throw new UiFailure('invalid_query', 'Query arguments may reference only declared local state.');
196
+ }
197
+ for (const value of Object.values(part)) {
198
+ references(value);
199
+ }
200
+ };
201
+ references(argument);
202
+ const args = evaluateUiExpression(argument, block.defaults, budget);
189
203
  if (typeof querySource !== 'string' || !args || Array.isArray(args) || typeof args !== 'object') {
190
204
  throw new UiFailure('invalid_query', 'Invalid query source or argument record.');
191
205
  }
@@ -196,7 +210,10 @@ export function compileUiBlock(source, options) {
196
210
  if (!checked.success) {
197
211
  throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
198
212
  }
199
- block.queries[name] = { source: querySource, args };
213
+ if (Object.keys(block.queries).length >= 8) {
214
+ throw new UiFailure('budget_exceeded', 'A block may declare at most eight queries.');
215
+ }
216
+ block.queries[name] = { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
200
217
  }
201
218
  else {
202
219
  if (!isConstant(expression)) {
@@ -230,37 +247,116 @@ export function compileUiBlock(source, options) {
230
247
  }
231
248
  return block;
232
249
  }
233
- export function compileUi(text, options) {
234
- const blocks = [];
235
- let fence = null;
236
- let position = 0;
237
- for (const line of text.split(/(?<=\n)/)) {
238
- const opening = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)/.exec(line);
239
- if (!fence && opening) {
240
- fence = {
241
- marker: opening[1][0],
242
- length: opening[1].length,
243
- start: position,
244
- codeStart: position + line.length,
245
- ui: opening[2].trim() === UI_FENCE_LANGUAGE
246
- };
247
- }
248
- else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
249
- if (fence.ui) {
250
- const block = compileUiBlock(text.slice(fence.codeStart, position), { ...options, id: `${options.id}:ui:${fence.start}`, final: true });
251
- block.start = fence.start;
252
- block.end = position + line.length;
253
- blocks.push(block);
250
+ export class UiCompiler {
251
+ options;
252
+ cache = new Map();
253
+ constructor(options) {
254
+ this.options = options;
255
+ }
256
+ compile(text, update = {}) {
257
+ const blocks = [];
258
+ const next = new Map();
259
+ let fence = null;
260
+ let position = 0;
261
+ let characters = 0;
262
+ let nodes = 0;
263
+ let exhausted = false;
264
+ const now = this.options.now ?? Date.now;
265
+ const started = now();
266
+ const nodeCount = (items) => items.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
267
+ const rejected = (start, codeStart, codeEnd, end, complete) => ({
268
+ id: `${this.options.id}:ui:${start}`,
269
+ catalogVersion: UI_CATALOG_VERSION,
270
+ start,
271
+ end,
272
+ complete,
273
+ defaults: {},
274
+ queries: {},
275
+ nodes: [],
276
+ fallback: text.slice(codeStart, Math.min(codeEnd, codeStart + 4096)),
277
+ diagnostics: [uiDiagnostic(new UiFailure('budget_exceeded', 'This reply exceeded its UI budget; remaining fences stay text.'), start, end)]
278
+ });
279
+ const append = (start, codeStart, codeEnd, end, closed) => {
280
+ if (exhausted) {
281
+ return;
282
+ }
283
+ if (blocks.length >= UI_REPLY_LIMITS.blocks) {
284
+ const last = blocks.at(-1);
285
+ blocks[blocks.length - 1] = {
286
+ ...last,
287
+ diagnostics: [
288
+ ...last.diagnostics.slice(0, 19),
289
+ uiDiagnostic(new UiFailure('budget_exceeded', 'Further UI fences stay text because this reply has too many blocks.'), start, end)
290
+ ]
291
+ };
292
+ exhausted = true;
293
+ return;
294
+ }
295
+ const complete = closed || update.final === true;
296
+ characters += codeEnd - codeStart;
297
+ if (characters > UI_REPLY_LIMITS.characters || now() - started > UI_REPLY_LIMITS.milliseconds) {
298
+ blocks.push(rejected(start, codeStart, codeEnd, end, complete));
299
+ exhausted = true;
300
+ return;
254
301
  }
255
- fence = null;
302
+ const source = text.slice(codeStart, codeEnd);
303
+ const cached = this.cache.get(start);
304
+ const unchanged = !update.final &&
305
+ cached?.source === source &&
306
+ cached.block.end === end &&
307
+ cached.block.complete === complete &&
308
+ cached.latestAttachment === update.latestAttachment;
309
+ const block = unchanged
310
+ ? cached.block
311
+ : compileUiBlock(source, {
312
+ ...this.options,
313
+ ...update,
314
+ id: `${this.options.id}:ui:${start}`,
315
+ final: complete
316
+ });
317
+ nodes += nodeCount(block.nodes);
318
+ if (nodes > UI_REPLY_LIMITS.nodes) {
319
+ blocks.push(rejected(start, codeStart, codeEnd, end, complete));
320
+ exhausted = true;
321
+ return;
322
+ }
323
+ block.start = start;
324
+ block.end = end;
325
+ blocks.push(block);
326
+ next.set(start, { source, block, latestAttachment: update.latestAttachment });
327
+ };
328
+ while (position < text.length && !exhausted) {
329
+ const newline = text.indexOf('\n', position);
330
+ const end = newline === -1 ? text.length : newline + 1;
331
+ const line = text.slice(position, end);
332
+ const opening = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)/.exec(line);
333
+ if (!fence && opening) {
334
+ fence = {
335
+ marker: opening[1][0],
336
+ length: opening[1].length,
337
+ start: position,
338
+ codeStart: position + line.length,
339
+ ui: opening[2].trim() === UI_FENCE_LANGUAGE
340
+ };
341
+ }
342
+ else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
343
+ if (fence.ui) {
344
+ append(fence.start, fence.codeStart, position, position + line.length, true);
345
+ }
346
+ fence = null;
347
+ }
348
+ position = end;
349
+ }
350
+ if (fence?.ui) {
351
+ append(fence.start, fence.codeStart, text.length, text.length, false);
256
352
  }
257
- position += line.length;
353
+ this.cache = next;
354
+ return blocks;
258
355
  }
259
- if (fence?.ui) {
260
- const block = compileUiBlock(text.slice(fence.codeStart), { ...options, id: `${options.id}:ui:${fence.start}` });
261
- block.start = fence.start;
262
- block.end = text.length;
263
- blocks.push(block);
356
+ clear() {
357
+ this.cache.clear();
264
358
  }
265
- return blocks;
359
+ }
360
+ export function compileUi(text, options) {
361
+ return new UiCompiler(options).compile(text, options);
266
362
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,10 @@ export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent,
2
2
  export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
3
3
  export { copyUiValue, evaluateUiExpression, parseUiExpression, type UiExpression, type UiValue } from './expression.ts';
4
4
  export { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntax, type UiSyntaxNode } from './syntax.ts';
5
- export { UI_FENCE_LANGUAGE, compileUi, compileUiBlock, type UiBlock, type UiCompileOptions, type UiNode, type UiQuery } from './compiler.ts';
6
- export { evaluateUiBlock, UiState, type UiBinding, type UiEvaluation, type UiViewNode } from './runtime.ts';
5
+ export { UI_FENCE_LANGUAGE, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock, type UiCompilerOptions, type UiCompileUpdate, type UiBlock, type UiCompileOptions, type UiNode, type UiQuery } from './compiler.ts';
6
+ export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, type UiChoiceSelection, type UiBinding, type UiEvaluation, type UiViewNode } from './runtime.ts';
7
+ export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
7
8
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
8
9
  export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.ts';
10
+ export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
11
+ export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
package/dist/index.js CHANGED
@@ -2,7 +2,10 @@ export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent,
2
2
  export { UI_LIMITS, UiBudget, UiFailure, safeKey } from './budget.js';
3
3
  export { copyUiValue, evaluateUiExpression, parseUiExpression } from './expression.js';
4
4
  export { parseUiSyntax, uiDiagnostic } from './syntax.js';
5
- export { UI_FENCE_LANGUAGE, compileUi, compileUiBlock } from './compiler.js';
6
- export { evaluateUiBlock, UiState } from './runtime.js';
5
+ export { UI_FENCE_LANGUAGE, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock } from './compiler.js';
6
+ export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice } from './runtime.js';
7
+ export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.js';
7
8
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.js';
8
9
  export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.js';
10
+ export { UI_STREAM_INTERVAL_MS, UiStream } from './stream.js';
11
+ export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets } from './links.js';
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import type { UiBlock } from './compiler.ts';
3
+ export declare const UiLinkTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
4
+ type: z.ZodLiteral<"File">;
5
+ path: z.ZodString;
6
+ line: z.ZodOptional<z.ZodNumber>;
7
+ }, z.core.$strict>, z.ZodObject<{
8
+ type: z.ZodLiteral<"Diff">;
9
+ path: z.ZodString;
10
+ }, z.core.$strict>, z.ZodObject<{
11
+ type: z.ZodLiteral<"Commit">;
12
+ sha: z.ZodString;
13
+ }, z.core.$strict>, z.ZodObject<{
14
+ type: z.ZodLiteral<"Node">;
15
+ id: z.ZodString;
16
+ }, z.core.$strict>], "type">;
17
+ export type UiLinkTarget = z.infer<typeof UiLinkTargetSchema>;
18
+ export declare const UiLinkResolutionSchema: z.ZodObject<{
19
+ state: z.ZodEnum<{
20
+ chip: "chip";
21
+ plain: "plain";
22
+ }>;
23
+ target: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
24
+ type: z.ZodLiteral<"File">;
25
+ path: z.ZodString;
26
+ line: z.ZodOptional<z.ZodNumber>;
27
+ }, z.core.$strict>, z.ZodObject<{
28
+ type: z.ZodLiteral<"Diff">;
29
+ path: z.ZodString;
30
+ }, z.core.$strict>, z.ZodObject<{
31
+ type: z.ZodLiteral<"Commit">;
32
+ sha: z.ZodString;
33
+ }, z.core.$strict>, z.ZodObject<{
34
+ type: z.ZodLiteral<"Node">;
35
+ id: z.ZodString;
36
+ }, z.core.$strict>], "type">>;
37
+ cwd: z.ZodOptional<z.ZodString>;
38
+ viewId: z.ZodOptional<z.ZodString>;
39
+ projectId: z.ZodOptional<z.ZodString>;
40
+ staged: z.ZodOptional<z.ZodBoolean>;
41
+ conflicted: z.ZodOptional<z.ZodBoolean>;
42
+ relativePath: z.ZodOptional<z.ZodString>;
43
+ label: z.ZodOptional<z.ZodString>;
44
+ reason: z.ZodOptional<z.ZodString>;
45
+ }, z.core.$strip>;
46
+ export type UiLinkResolution = z.infer<typeof UiLinkResolutionSchema>;
47
+ export declare function uiLinkTargets(block: UiBlock, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>): Record<string, UiLinkTarget>;
package/dist/links.js ADDED
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ import { evaluateUiBlock } from './runtime.js';
3
+ import { uiValidatedState } from './query.js';
4
+ export const UiLinkTargetSchema = z.discriminatedUnion('type', [
5
+ z.object({ type: z.literal('File'), path: z.string().min(1).max(4096), line: z.number().int().positive().optional() }).strict(),
6
+ z.object({ type: z.literal('Diff'), path: z.string().min(1).max(4096) }).strict(),
7
+ z.object({ type: z.literal('Commit'), sha: z.string().regex(/^[a-f\d]{7,64}$/i) }).strict(),
8
+ z.object({ type: z.literal('Node'), id: z.string().min(1).max(256) }).strict()
9
+ ]);
10
+ export const UiLinkResolutionSchema = z.object({
11
+ state: z.enum(['chip', 'plain']),
12
+ target: UiLinkTargetSchema.optional(),
13
+ cwd: z.string().optional(),
14
+ viewId: z.string().optional(),
15
+ projectId: z.string().optional(),
16
+ staged: z.boolean().optional(),
17
+ conflicted: z.boolean().optional(),
18
+ relativePath: z.string().optional(),
19
+ label: z.string().optional(),
20
+ reason: z.string().optional()
21
+ });
22
+ export function uiLinkTargets(block, input = {}, queries = {}) {
23
+ const state = uiValidatedState(block, input, queries);
24
+ const targets = Object.create(null);
25
+ const visit = (nodes) => {
26
+ for (const node of nodes) {
27
+ if (node.error || !node.complete) {
28
+ continue;
29
+ }
30
+ const checked = UiLinkTargetSchema.safeParse({ type: node.type, ...node.props });
31
+ if (checked.success) {
32
+ targets[node.id] = checked.data;
33
+ }
34
+ visit(node.children);
35
+ }
36
+ };
37
+ visit(evaluateUiBlock(block, state).nodes);
38
+ return targets;
39
+ }
package/dist/protocol.js CHANGED
@@ -25,11 +25,12 @@ export const UiDiagnosticSchema = z.object({
25
25
  export const UiBlockSchema = z.object({
26
26
  id: z.string(),
27
27
  catalogVersion: position,
28
+ revision: z.string().min(1).optional(),
28
29
  start: position,
29
30
  end: position,
30
31
  complete: z.boolean(),
31
32
  defaults: values,
32
- queries: z.record(z.string(), z.object({ source: z.string(), args: values })),
33
+ queries: z.record(z.string(), z.object({ source: z.string(), args: values, expression: z.unknown().optional() })),
33
34
  nodes: z.array(UiNodeSchema),
34
35
  diagnostics: z.array(UiDiagnosticSchema),
35
36
  fallback: z.string()
@@ -0,0 +1,6 @@
1
+ import type { UiBlock } from './compiler.ts';
2
+ import { type UiValue } from './expression.ts';
3
+ import { UiState } from './runtime.ts';
4
+ export declare function uiValidatedState(block: UiBlock, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>): UiState;
5
+ export declare function uiQueryArguments(block: UiBlock, name: string, input?: Readonly<Record<string, unknown>>): Record<string, UiValue>;
6
+ export declare function uiQueryFallback(block: UiBlock, queries: Readonly<Record<string, unknown>>): string;
package/dist/query.js ADDED
@@ -0,0 +1,115 @@
1
+ import { UI_CATALOG_VERSION } from './catalog.js';
2
+ import { UiBudget, UiFailure } from './budget.js';
3
+ import { copyUiValue, evaluateUiExpression } from './expression.js';
4
+ import { evaluateUiBlock, uiInputValues, UiState } from './runtime.js';
5
+ function findBinding(nodes, id, prop) {
6
+ for (const node of nodes) {
7
+ if (node.id === id) {
8
+ return node.bindings[prop];
9
+ }
10
+ const found = findBinding(node.children, id, prop);
11
+ if (found) {
12
+ return found;
13
+ }
14
+ }
15
+ return undefined;
16
+ }
17
+ export function uiValidatedState(block, input = {}, queries = {}) {
18
+ if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
19
+ throw new UiFailure('invalid_block', 'Only completed supported blocks can resolve input.');
20
+ }
21
+ const values = copyUiValue(input, new UiBudget());
22
+ const state = new UiState(block);
23
+ for (const [name, value] of Object.entries(queries)) {
24
+ state.setQuery(name, value, block);
25
+ }
26
+ const allowed = uiInputValues(block, state);
27
+ for (const [key, value] of Object.entries(values)) {
28
+ if (!Object.hasOwn(allowed, key)) {
29
+ throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
30
+ }
31
+ state.set(key, value);
32
+ }
33
+ const visible = new Set();
34
+ const visit = (nodes) => {
35
+ for (const node of nodes) {
36
+ if (node.error || !node.complete) {
37
+ continue;
38
+ }
39
+ let valid = true;
40
+ if (node.type === 'Segmented') {
41
+ valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
42
+ }
43
+ if (node.type === 'Checklist') {
44
+ const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
45
+ valid = node.props.value.every((value) => options.includes(value));
46
+ }
47
+ if (valid) {
48
+ for (const [key, binding] of Object.entries(node.bindings)) {
49
+ const original = findBinding(block.nodes, node.sourceId ?? node.id, key);
50
+ if (original && binding) {
51
+ visible.add(original);
52
+ }
53
+ }
54
+ }
55
+ visit(node.children);
56
+ }
57
+ };
58
+ const evaluated = evaluateUiBlock(block, state);
59
+ if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
60
+ throw new UiFailure('budget_exceeded', 'The query inputs exceeded their evaluation budget.');
61
+ }
62
+ visit(evaluated.nodes);
63
+ for (const [key, value] of Object.entries(values)) {
64
+ if (JSON.stringify(value) !== JSON.stringify(allowed[key]) && !visible.has(key)) {
65
+ throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
66
+ }
67
+ }
68
+ return state;
69
+ }
70
+ export function uiQueryArguments(block, name, input = {}) {
71
+ if (!Object.hasOwn(block.queries, name)) {
72
+ throw new UiFailure('invalid_query', 'This block does not declare that query.');
73
+ }
74
+ const state = uiValidatedState(block, input);
75
+ const query = block.queries[name];
76
+ return query.expression
77
+ ? evaluateUiExpression(query.expression, state.scope(), new UiBudget())
78
+ : copyUiValue(query.args, new UiBudget());
79
+ }
80
+ export function uiQueryFallback(block, queries) {
81
+ const state = new UiState(block);
82
+ for (const [name, value] of Object.entries(queries)) {
83
+ state.setQuery(name, value, block);
84
+ }
85
+ const plain = (value) => (value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value));
86
+ const text = (nodes) => nodes
87
+ .map((node) => {
88
+ if (node.error) {
89
+ return node.fallback;
90
+ }
91
+ if (node.type === '$text') {
92
+ return plain(node.props.text);
93
+ }
94
+ const props = node.props;
95
+ const children = text(node.children);
96
+ if (node.type === 'Stat') {
97
+ return `${plain(props.label)}: ${plain(props.value)}${props.unit ? ` ${plain(props.unit)}` : ''}\n`;
98
+ }
99
+ if (node.type === 'Table') {
100
+ return `${plain(props.rows)}\n`;
101
+ }
102
+ if (node.type === 'Chart') {
103
+ return `${plain(props.data)}\n`;
104
+ }
105
+ if (node.type === 'Source') {
106
+ return `${plain(props.label)}: ${plain(props.url)}\n`;
107
+ }
108
+ if (node.type === 'Image') {
109
+ return node.fallback;
110
+ }
111
+ return `${(props.label ?? props.title) ? `${plain(props.label ?? props.title)}: ` : ''}${plain(props.path ?? props.sha ?? (node.type === 'Node' ? props.id : null))}${children}${props.context ? `\n${plain(props.context)}` : ''}${node.type === 'Tag' ? '' : '\n'}`;
112
+ })
113
+ .join('');
114
+ return text(evaluateUiBlock(block, state).nodes).trim() || block.fallback;
115
+ }
package/dist/runtime.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface UiBinding<Value> {
8
8
  }
9
9
  export interface UiViewNode<Props = Record<string, unknown>> {
10
10
  id: string;
11
+ sourceId?: string;
11
12
  type: string;
12
13
  props: Props;
13
14
  bindings: Readonly<Record<string, UiBinding<unknown>>>;
@@ -39,3 +40,10 @@ export declare class UiState {
39
40
  private changed;
40
41
  }
41
42
  export declare function evaluateUiBlock(block: UiBlock, state?: UiState, limits?: Partial<UiLimits>): UiEvaluation;
43
+ export interface UiChoiceSelection {
44
+ label: string;
45
+ context: string;
46
+ values: Record<string, UiValue>;
47
+ }
48
+ export declare function uiInputValues(block: UiBlock, state: UiState): Record<string, UiValue>;
49
+ export declare function resolveUiChoice(block: UiBlock, choiceId: string, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>): UiChoiceSelection;
package/dist/runtime.js CHANGED
@@ -119,6 +119,7 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
119
119
  const visit = (node, variables, depth, suffix = '') => {
120
120
  const view = {
121
121
  id: `${node.id}${suffix}`,
122
+ sourceId: node.id,
122
123
  type: node.type,
123
124
  props: {},
124
125
  bindings: {},
@@ -212,3 +213,107 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
212
213
  }
213
214
  return result;
214
215
  }
216
+ export function uiInputValues(block, state) {
217
+ const scope = state.scope();
218
+ const values = Object.create(null);
219
+ const visit = (nodes) => {
220
+ for (const node of nodes) {
221
+ if (isUiComponent(node.type) && !node.error && node.complete) {
222
+ const entry = UI_CATALOG[node.type];
223
+ for (const [prop, name] of Object.entries(node.bindings)) {
224
+ if ('binding' in entry && entry.binding === prop && Object.hasOwn(block.defaults, name) && !Object.hasOwn(block.queries, name)) {
225
+ values[name] = scope[name];
226
+ }
227
+ }
228
+ }
229
+ visit(node.children);
230
+ }
231
+ };
232
+ visit(block.nodes);
233
+ return values;
234
+ }
235
+ /* The host supplies its stored block; client data can change only declared input bindings. */
236
+ export function resolveUiChoice(block, choiceId, input = {}, queries = {}) {
237
+ if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
238
+ throw new UiFailure('refused_choice', 'Only a completed supported block can send a choice.');
239
+ }
240
+ const values = copyUiValue(input, new UiBudget());
241
+ const state = new UiState(block);
242
+ for (const [name, value] of Object.entries(queries)) {
243
+ state.setQuery(name, value, block);
244
+ }
245
+ const allowed = uiInputValues(block, state);
246
+ const changed = new Set();
247
+ for (const [name, value] of Object.entries(values)) {
248
+ if (!Object.hasOwn(allowed, name)) {
249
+ throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
250
+ }
251
+ if (JSON.stringify(value) !== JSON.stringify(allowed[name])) {
252
+ state.set(name, value);
253
+ changed.add(name);
254
+ }
255
+ }
256
+ const evaluated = evaluateUiBlock(block, state);
257
+ if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
258
+ throw new UiFailure('budget_exceeded', 'The choice exceeded its evaluation budget.');
259
+ }
260
+ const validInputs = new Set();
261
+ let choice;
262
+ const written = new Map();
263
+ const remember = (nodes) => {
264
+ for (const node of nodes) {
265
+ written.set(node.id, node);
266
+ remember(node.children);
267
+ }
268
+ };
269
+ remember(block.nodes);
270
+ const visit = (nodes, parent) => {
271
+ for (const node of nodes) {
272
+ const source = written.get(node.sourceId ?? node.id);
273
+ if (node.error && source && Object.values(source.bindings).some((name) => changed.has(name))) {
274
+ throw new UiFailure('invalid_value', 'An input value does not match its control.');
275
+ }
276
+ if (node.error || !node.complete) {
277
+ continue;
278
+ }
279
+ let valid = true;
280
+ if (node.type === 'Segmented') {
281
+ valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
282
+ }
283
+ else if (node.type === 'Checklist') {
284
+ const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
285
+ valid = node.props.value.every((value) => options.includes(value));
286
+ }
287
+ if (valid && source) {
288
+ for (const prop of Object.keys(node.bindings)) {
289
+ validInputs.add(source.bindings[prop]);
290
+ }
291
+ }
292
+ if (node.id === choiceId && node.type === 'Choice' && parent === 'Choices' && node.props.disabled !== true) {
293
+ choice = node;
294
+ }
295
+ visit(node.children, node.type);
296
+ }
297
+ };
298
+ visit(evaluated.nodes);
299
+ if ([...changed].some((name) => !validInputs.has(name))) {
300
+ throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
301
+ }
302
+ if (!choice) {
303
+ throw new UiFailure('refused_choice', 'This choice is not available in the current block.');
304
+ }
305
+ const text = (nodes) => nodes.map((node) => (node.type === '$text' ? String(node.props.text ?? '') : text(node.children))).join('');
306
+ const hasError = (nodes) => nodes.some((node) => node.error !== undefined || hasError(node.children));
307
+ if (hasError(choice.children)) {
308
+ throw new UiFailure('refused_choice', 'The choice label could not be fully read.');
309
+ }
310
+ const label = text(choice.children).replace(/\s+/g, ' ').trim();
311
+ if (!label) {
312
+ throw new UiFailure('refused_choice', 'A choice needs a visible label.');
313
+ }
314
+ return {
315
+ label,
316
+ values: uiInputValues(block, state),
317
+ context: typeof choice.props.context === 'string' && choice.props.context.trim() ? choice.props.context : label
318
+ };
319
+ }