@adecore/intelligent-ui 0.0.0 → 0.21.1

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
@@ -1,72 +1,44 @@
1
1
  # @adecore/intelligent-ui
2
2
 
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.
3
+ [![npm](https://img.shields.io/npm/v/@adecore/intelligent-ui)](https://www.npmjs.com/package/@adecore/intelligent-ui)
4
+ [![Docs](https://img.shields.io/badge/docs-adecore.dev-blue)](https://adecore.dev/intelligent-ui/)
4
5
 
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.
6
+ Lets an agent answer with a block of components instead of prose: a summary, a table, a checklist, the next steps a person picks from. The package holds the catalog, a compiler that reads blocks out of a reply while it streams, and a bounded interpreter for their state and expressions. No framework and no DOM: a renderer draws what it returns. A block cannot run code, load an address or act outside itself.
6
7
 
7
- ```ts
8
- import { compileUi, evaluateUiBlock, UiState } from '@adecore/intelligent-ui';
9
-
10
- const blocks = compileUi(reply, { id: assistantItemId, final: true });
11
- for (const block of blocks) {
12
- const state = new UiState(block);
13
- const view = evaluateUiBlock(block, state);
14
- render(view.nodes);
15
- }
16
- ```
17
-
18
- ## Compilation
19
-
20
- `compileUi(text, options)` finds blocks marked with `UI_FENCE_LANGUAGE` among Markdown, including unfinished fences. It ignores UI fences nested inside another code fence. Each `UiBlock` has its own ID, text range, catalog version, declarations, nodes, diagnostics and Markdown fallback. `compileUiBlock(source, options)` compiles one fence's contents.
21
-
22
- `UiCompileOptions.id` identifies the assistant item or block. Supply the same ID on each recompile. Node IDs include the start of their tag, so appending text preserves them. `final` reports unfinished syntax when the authoritative response arrives. `latestAttachment` resolves `Image generated="latest"` to an attachment. `limits` overrides the work budget; `now` supplies a clock for deterministic tests.
23
-
24
- The parser recovers at the next tag or line after malformed input. Open tags remain incomplete. A streamed quoted prop exposes the text already received. CodeBlock contents are literal, including angle brackets and braces. Unknown components retain their own fallback; unknown props produce `refused_prop` and are dropped. A budget failure replaces the whole compiled block with a bounded fallback and one diagnosis.
25
-
26
- `UiNode` keeps static `props` separate from `expressions` and local-variable `bindings`. Its `type` is a string so an unfamiliar component can fall back without rejecting neighboring content. `UiNodeSchema`, `UiBlockSchema`, `UiBlocksSchema` and `UiDiagnosticSchema` describe the wire envelope. Component names, props and expressions stay open, so a future catalog can be retained and shown as fallback. The optional assistant field and daemon integration still await the package's first publication setup.
8
+ **[Documentation](https://adecore.dev/intelligent-ui/)**
27
9
 
28
- `parseUiSyntax` exposes the intermediate `UiSyntax` and `UiSyntaxNode` for compiler tooling. `UiDiagnostic` carries a code, message, source range and optional node ID. `uiDiagnostic` turns a caught failure into that shape.
10
+ ## Install
29
11
 
30
- ## Catalog
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.
33
-
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
-
36
- | Group | Components |
37
- | --- | --- |
38
- | Status | Summary, Callout, Tag, Progress, Steps / Step |
39
- | Data | Stats / Stat, EntityList / Entry, Table / Column, Chart |
40
- | Structure | Tabs / Tab, Sections / Section |
41
- | Content | CodeBlock, Image, Sources / Source |
42
- | Host links | File, Diff, Commit, Node |
43
- | Local inputs | Checklist / Item, Switch, Slider, Segmented / Option, Show, Each |
44
- | Choices | Choices / Choice |
45
-
46
- Image accepts exactly one of `attachment` and `generated="latest"`, plus an optional alt and caption. It never takes a URL or a filesystem path. Charts use rows with a label and numeric series; their renderer will validate usable series before drawing. A Column names a field and optionally its title, unit and presentation.
47
-
48
- ## Expressions and state
12
+ ```sh
13
+ bun add @adecore/intelligent-ui
14
+ ```
49
15
 
50
- `parseUiExpression(source, budget?)` returns a `UiExpression` AST. `evaluateUiExpression(ast, variables?, budget?)` returns a finite JSON `UiValue`. It supports scalar, array and object literals, variables, own field/index reads, arithmetic, strict comparisons, boolean operators and these helpers:
16
+ ## Use
51
17
 
52
- | Helper | Arguments |
53
- | --- | --- |
54
- | `@Count` | list or string |
55
- | `@Filter` | list, local name, boolean predicate, e.g. `@Filter($rows, row, row.failed)` |
56
- | `@Sum` | numeric list, or record list and a field name |
57
- | `@Join` | scalar list and optional separator |
58
- | `@Round` | number and optional precision from zero to six |
18
+ ```ts
19
+ import { compileUi, evaluateUiBlock, uiSessionNote, UiState } from '@adecore/intelligent-ui';
59
20
 
60
- There is no JavaScript execution, general function call, assignment, network access or inherited field access. `__proto__`, `constructor` and `prototype` are refused. `copyUiValue` validates and copies JSON input without invoking accessors; class instances, nonfinite numbers, sparse arrays and cycles are refused.
21
+ const instructions = uiSessionNote(); // tell the agent the language
61
22
 
62
- `UiState` belongs to one chat/item/block identity. `sync(block)` preserves edits when a declaration's default is unchanged, resets changed defaults, removes disappeared variables and clears changed query data. A reload creates a new state from defaults. `scope()` returns a safe copy. `set(name, value)` changes a declared local variable without changing its type. `run(ast)` accepts only `@Set($name, expression)`, `@Reset($name)` and `@Reset()`. `snapshot()` and `subscribe(listener)` let a renderer observe input edits.
23
+ const blocks = compileUi(replyText, { id: replyId, final: true });
24
+ for (const block of blocks) {
25
+ const state = new UiState(block);
26
+ render(evaluateUiBlock(block, state).nodes);
27
+ }
28
+ ```
63
29
 
64
- `evaluateUiBlock(block, state?, limits?)` returns `UiEvaluation`, including `UiViewNode` objects and per-element diagnostics. View nodes contain validated evaluated props, children and `UiBinding` callbacks. A binding changes state only after its input node is complete and the new prop passes its schema. Show and Each are resolved before rendering. Repetition reserves iterations before allocating children. Unknown catalog versions and invalid elements retain their fallback.
30
+ `@adecore/agents` compiles the blocks of a chat session, and `@adecore/agents-react` draws them with live data, links and choices.
65
31
 
66
- `$name = @Query("source", {literalArgs})` requires a matching schema in `UiCompileOptions.querySchemas`. It yields a read-only `UiQuery`; ordinary value evaluation never performs a query. The authorized host may supply data with `state.setQuery(name, value, block)`. Results for replaced query definitions are refused. Transport, quotas, rights, fixed versus variable arguments and visible-only refresh still belong to the upcoming host integration.
32
+ ## Documentation
67
33
 
68
- ## Budgets and acceptance
34
+ | Page | What it covers |
35
+ |---|---|
36
+ | [Getting started](https://adecore.dev/intelligent-ui/guide/getting-started) | Instructions, compilation, the wire and rendering |
37
+ | [Security model](https://adecore.dev/intelligent-ui/guide/security) | What a block can and cannot do |
38
+ | [Syntax](https://adecore.dev/intelligent-ui/language/syntax) | The language an agent writes |
39
+ | [Components](https://adecore.dev/intelligent-ui/components/status) | Every component and its props |
40
+ | [Live queries](https://adecore.dev/intelligent-ui/host/queries) | Data a host reads for a block |
69
41
 
70
- `UI_LIMITS` bounds characters, nesting, nodes, diagnoses, steps, iterations, string length and elapsed time. `UiLimits` describes overrides. `UiBudget` counts work inside helpers as well as ordinary evaluation. `UiFailure` carries a machine-readable refusal code; `safeKey` applies the forbidden-field rule.
42
+ ## License
71
43
 
72
- The tests compile ten hand-written examples and every character prefix, then exercise malformed syntax, prototype/accessor attacks, helper budgets, output expansion, query replacement and state reset. Recorded Claude/Codex responses, daemon event-loop/log measurements and iPhone JavaScriptCore parity remain acceptance work. The package does not yet render replies in an application.
44
+ FSL-1.1-MIT, see [LICENSE](./LICENSE).
@@ -0,0 +1,5 @@
1
+ import type { UiBlock } from './compiler.ts';
2
+ import type { UiExpression } from './expression.ts';
3
+ export declare function uiActionProp(type: string): string | undefined;
4
+ export declare function uiActionTarget(action: UiExpression, local: (name: string) => boolean): string | null;
5
+ export declare function uiLocalState(block: Pick<UiBlock, 'defaults' | 'queries'>): (name: string) => boolean;
@@ -0,0 +1,34 @@
1
+ import { UiFailure } from './budget.js';
2
+ import { isUiComponent, UI_CATALOG } from './catalog.js';
3
+ /* The prop of a component that holds a state action, kept unevaluated, instead of a value. */
4
+ export function uiActionProp(type) {
5
+ if (!isUiComponent(type)) {
6
+ return undefined;
7
+ }
8
+ const entry = UI_CATALOG[type];
9
+ return 'action' in entry ? entry.action : undefined;
10
+ }
11
+ /*
12
+ * The variable an action writes, or null for `@Reset()`, which restores them all. Throws for anything
13
+ * but `@Set($name, value)`, `@Reset($name)` and `@Reset()` on a variable that `local` accepts.
14
+ */
15
+ export function uiActionTarget(action, local) {
16
+ if (action.kind !== 'call' || (action.name !== 'Set' && action.name !== 'Reset')) {
17
+ throw new UiFailure('refused_action', 'Expected @Set or @Reset.');
18
+ }
19
+ if (action.name === 'Reset' && action.args.length === 0) {
20
+ return null;
21
+ }
22
+ if (action.args.length !== (action.name === 'Set' ? 2 : 1)) {
23
+ throw new UiFailure('refused_action', 'Invalid state action arguments.');
24
+ }
25
+ const reference = action.args[0];
26
+ if (reference.kind !== 'reference' || !local(reference.name)) {
27
+ throw new UiFailure('refused_binding', 'The action needs a local state variable.');
28
+ }
29
+ return reference.name;
30
+ }
31
+ /* Whether `name` is local state of the block: declared with a default and never a query. */
32
+ export function uiLocalState(block) {
33
+ return (name) => Object.hasOwn(block.defaults, name) && !Object.hasOwn(block.queries, name);
34
+ }
package/dist/catalog.d.ts CHANGED
@@ -31,7 +31,7 @@ export declare const UI_GROUPS: {
31
31
  };
32
32
  readonly inputs: {
33
33
  readonly description: 'Change local block state.';
34
- readonly rules: 'Bind value={$name}. State stays local until a Choice sends visible context.';
34
+ readonly rules: 'Bind value={$name}. A Button sets a literal with action={@Set($name, value)} or restores a default with {@Reset($name)} or {@Reset()}. State stays local until a Choice sends visible context.';
35
35
  };
36
36
  readonly choices: {
37
37
  readonly description: 'Ask the person to pick a next step.';
@@ -330,6 +330,14 @@ export declare const UI_CATALOG: {
330
330
  }, z.core.$strict>;
331
331
  readonly parent: 'Segmented';
332
332
  };
333
+ readonly Button: {
334
+ readonly group: 'inputs';
335
+ readonly description: 'Run one local state action when pressed; children are the label.';
336
+ readonly schema: z.ZodObject<{
337
+ disabled: z.ZodOptional<z.ZodBoolean>;
338
+ }, z.core.$strict>;
339
+ readonly action: 'action';
340
+ };
333
341
  readonly Show: {
334
342
  readonly group: 'inputs';
335
343
  readonly description: 'Show children when the condition is true.';
package/dist/catalog.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { uiPropList } from './prop-list.js';
2
3
  export const UI_CATALOG_VERSION = 1;
3
4
  export const UiToneSchema = z.enum(['neutral', 'info', 'success', 'warning', 'danger']);
4
5
  const text = z.string().max(16384);
@@ -17,7 +18,10 @@ export const UI_GROUPS = {
17
18
  rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.'
18
19
  },
19
20
  links: { description: 'Refer to a host-owned resource.', rules: 'Targets are checked by the host. No arbitrary navigation or file reads.' },
20
- inputs: { description: 'Change local block state.', rules: 'Bind value={$name}. State stays local until a Choice sends visible context.' },
21
+ inputs: {
22
+ description: 'Change local block state.',
23
+ rules: 'Bind value={$name}. A Button sets a literal with action={@Set($name, value)} or restores a default with {@Reset($name)} or {@Reset()}. State stays local until a Choice sends visible context.'
24
+ },
21
25
  choices: {
22
26
  description: 'Ask the person to pick a next step.',
23
27
  rules: 'Give a short label and precise visible context. A Choice sends once after streaming ends.'
@@ -115,6 +119,12 @@ export const UI_CATALOG = {
115
119
  },
116
120
  Segmented: { group: 'inputs', description: 'Choose one local value.', schema: z.strictObject({ value: scalar }), children: ['Option'], binding: 'value' },
117
121
  Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: 'Segmented' },
122
+ Button: {
123
+ group: 'inputs',
124
+ description: 'Run one local state action when pressed; children are the label.',
125
+ schema: z.strictObject({ disabled: z.boolean().optional() }),
126
+ action: 'action'
127
+ },
118
128
  Show: { group: 'inputs', description: 'Show children when the condition is true.', schema: z.strictObject({ when: z.boolean() }) },
119
129
  Each: {
120
130
  group: 'inputs',
@@ -134,12 +144,6 @@ export function isUiComponent(name) {
134
144
  }
135
145
  export function uiCatalogText() {
136
146
  return Object.entries(UI_CATALOG)
137
- .map(([name, entry]) => {
138
- const shape = entry.schema.shape;
139
- const props = Object.entries(shape)
140
- .map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
141
- .join(', ');
142
- return `${name}(${props}): ${entry.description}`;
143
- })
147
+ .map(([name, entry]) => `${name}(${uiPropList(entry).join(', ')}): ${entry.description}`)
144
148
  .join('\n');
145
149
  }
@@ -2,7 +2,19 @@ import { type UiLimits } from './budget.ts';
2
2
  import { type UiExpression, type UiValue } from './expression.ts';
3
3
  import { type UiDiagnostic } from './syntax.ts';
4
4
  import { type z } from 'zod';
5
- export declare const UI_FENCE_LANGUAGE = "ruimte-ui";
5
+ export declare const UI_FENCE_LANGUAGE = "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
+ };
12
+ export declare const UI_HOST_LIMITS: {
13
+ readonly queries: 8;
14
+ readonly links: 64;
15
+ readonly refreshMilliseconds: 10000;
16
+ };
17
+ export declare function uiMayReferenceHost(text: string): boolean;
6
18
  export interface UiNode {
7
19
  id: string;
8
20
  type: string;
@@ -19,10 +31,12 @@ export interface UiNode {
19
31
  export interface UiQuery {
20
32
  source: string;
21
33
  args: Record<string, UiValue>;
34
+ expression?: UiExpression;
22
35
  }
23
36
  export interface UiBlock {
24
37
  id: string;
25
38
  catalogVersion: number;
39
+ revision?: string;
26
40
  start: number;
27
41
  end: number;
28
42
  complete: boolean;
@@ -38,7 +52,18 @@ export interface UiCompileOptions {
38
52
  limits?: Partial<UiLimits>;
39
53
  latestAttachment?: string;
40
54
  querySchemas?: Readonly<Record<string, z.ZodType>>;
55
+ fenceLanguage?: string;
41
56
  now?: () => number;
42
57
  }
43
58
  export declare function compileUiBlock(source: string, options: UiCompileOptions): UiBlock;
59
+ export type UiCompilerOptions = Omit<UiCompileOptions, 'final' | 'latestAttachment'>;
60
+ export type UiCompileUpdate = Pick<UiCompileOptions, 'final' | 'latestAttachment'>;
61
+ export declare class UiCompiler {
62
+ private readonly options;
63
+ private cache;
64
+ constructor(options: UiCompilerOptions);
65
+ compile(text: string, update?: UiCompileUpdate): UiBlock[];
66
+ clear(): void;
67
+ }
68
+ export declare function uiHasFence(text: string, fenceLanguage?: string): boolean;
44
69
  export declare function compileUi(text: string, options: UiCompileOptions): UiBlock[];
package/dist/compiler.js CHANGED
@@ -1,9 +1,25 @@
1
- import { safeKey, UiBudget, UiFailure } from './budget.js';
1
+ import { safeKey, UI_LIMITS, UiBudget, UiFailure } from './budget.js';
2
2
  import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.js';
3
+ import { uiActionProp, uiActionTarget, uiLocalState } from './actions.js';
4
+ import { uiNodeFallback, uiPlainText } from './fallback.js';
3
5
  import { evaluateUiExpression } from './expression.js';
4
6
  import { parseUiSyntax, uiDiagnostic } from './syntax.js';
5
7
  import {} from 'zod';
6
- export const UI_FENCE_LANGUAGE = 'ruimte-ui';
8
+ // The info string a fence carries when the host names none of its own.
9
+ export const UI_FENCE_LANGUAGE = 'ui';
10
+ export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 };
11
+ // What a host reads for one block: its queries, its link targets and how often a visible block reads again.
12
+ export const UI_HOST_LIMITS = { queries: 8, links: 64, refreshMilliseconds: 10_000 };
13
+ /* A cheap test for whether `text` may declare a query or name a link target, before anything is read for it. */
14
+ export function uiMayReferenceHost(text) {
15
+ if (text.includes('@Query')) {
16
+ return true;
17
+ }
18
+ const links = Object.entries(UI_CATALOG)
19
+ .filter(([, entry]) => entry.group === 'links')
20
+ .map(([name]) => name);
21
+ return new RegExp(`<(?:${links.join('|')})\\b`).test(text);
22
+ }
7
23
  function isConstant(expression) {
8
24
  switch (expression.kind) {
9
25
  case 'literal':
@@ -18,9 +34,6 @@ function isConstant(expression) {
18
34
  return false;
19
35
  }
20
36
  }
21
- function plain(value) {
22
- return value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
23
- }
24
37
  export function compileUiBlock(source, options) {
25
38
  const budget = new UiBudget(options.limits, options.now);
26
39
  const block = {
@@ -40,8 +53,16 @@ export function compileUiBlock(source, options) {
40
53
  block.diagnostics.push(uiDiagnostic(error, start, end, nodeId));
41
54
  }
42
55
  };
43
- const compile = (syntax, parent) => {
44
- budget.depth(0);
56
+ // A pressed action must set the same value whatever state it is pressed in, so a choice can check it.
57
+ const checkedAction = (action) => {
58
+ uiActionTarget(action, uiLocalState(block));
59
+ if (action.kind === 'call' && action.name === 'Set' && !isConstant(action.args[1])) {
60
+ throw new UiFailure('refused_action', '@Set in an action takes a literal value.');
61
+ }
62
+ return action;
63
+ };
64
+ const compile = (syntax, depth, parent) => {
65
+ budget.depth(depth);
45
66
  const node = {
46
67
  id: syntax.id,
47
68
  type: syntax.type,
@@ -60,7 +81,7 @@ export function compileUiBlock(source, options) {
60
81
  if (syntax.expression) {
61
82
  node.expressions.text = syntax.expression;
62
83
  try {
63
- node.fallback = plain(evaluateUiExpression(syntax.expression, block.defaults, budget));
84
+ node.fallback = uiPlainText(evaluateUiExpression(syntax.expression, block.defaults, budget));
64
85
  }
65
86
  catch (error) {
66
87
  if (error instanceof UiFailure && error.code === 'budget_exceeded') {
@@ -90,8 +111,13 @@ export function compileUiBlock(source, options) {
90
111
  throw new UiFailure('invalid_child', `${syntax.type} is not a child of ${parent}.`);
91
112
  }
92
113
  }
114
+ const actionProp = uiActionProp(syntax.type);
93
115
  for (const [key, expression] of Object.entries(syntax.props)) {
94
116
  safeKey(key);
117
+ if (key === actionProp) {
118
+ node.expressions[key] = checkedAction(expression);
119
+ continue;
120
+ }
95
121
  if (!Object.hasOwn(entry.schema.shape, key)) {
96
122
  diagnose(new UiFailure('refused_prop', `${syntax.type} does not accept ${key}.`), syntax.start, syntax.end, syntax.id);
97
123
  continue;
@@ -101,11 +127,7 @@ export function compileUiBlock(source, options) {
101
127
  }
102
128
  else {
103
129
  node.expressions[key] = expression;
104
- if ('binding' in entry &&
105
- entry.binding === key &&
106
- expression.kind === 'reference' &&
107
- Object.hasOwn(block.defaults, expression.name) &&
108
- !Object.hasOwn(block.queries, expression.name)) {
130
+ if ('binding' in entry && entry.binding === key && expression.kind === 'reference' && uiLocalState(block)(expression.name)) {
109
131
  node.bindings[key] = expression.name;
110
132
  }
111
133
  }
@@ -114,10 +136,16 @@ export function compileUiBlock(source, options) {
114
136
  delete node.props.generated;
115
137
  node.props.attachment = options.latestAttachment;
116
138
  }
139
+ if (actionProp !== undefined && syntax.complete && !node.expressions[actionProp]) {
140
+ throw new UiFailure('invalid_props', `${syntax.type} needs ${actionProp}.`);
141
+ }
117
142
  const evaluated = { ...node.props };
118
143
  fallbackProps = evaluated;
119
144
  let unresolved = false;
120
145
  for (const [key, expression] of Object.entries(node.expressions)) {
146
+ if (key === actionProp) {
147
+ continue;
148
+ }
121
149
  try {
122
150
  evaluated[key] = evaluateUiExpression(expression, block.defaults, budget);
123
151
  }
@@ -144,32 +172,8 @@ export function compileUiBlock(source, options) {
144
172
  diagnose(error, syntax.start, syntax.end, syntax.id);
145
173
  }
146
174
  const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
147
- node.children = syntax.children.map((child) => compile(child, effectiveParent));
148
- const children = node.children.map((child) => child.fallback).join('');
149
- const label = fallbackProps.label ?? fallbackProps.title;
150
- const value = fallbackProps.value;
151
- const target = fallbackProps.path ?? fallbackProps.sha ?? (syntax.type === 'Node' ? fallbackProps.id : undefined);
152
- if (syntax.type === 'Stat') {
153
- node.fallback = `${plain(label ?? '')}: ${plain(value ?? '')}${fallbackProps.unit ? ` ${plain(fallbackProps.unit)}` : ''}\n`;
154
- }
155
- else if (syntax.type === 'Source') {
156
- node.fallback = `${plain(label ?? '')}: ${plain(fallbackProps.url ?? '')}\n`;
157
- }
158
- else if (syntax.type === 'Table') {
159
- node.fallback = `${plain(fallbackProps.rows ?? [])}\n`;
160
- }
161
- else if (syntax.type === 'Chart') {
162
- node.fallback = `${plain(fallbackProps.data ?? [])}\n`;
163
- }
164
- else if (syntax.type === 'Image') {
165
- node.fallback = `[Image: ${plain(fallbackProps.alt ?? fallbackProps.attachment ?? fallbackProps.generated ?? 'unavailable')}]${children}\n`;
166
- }
167
- else {
168
- node.fallback = `${label !== undefined ? `${plain(label)}: ` : ''}${target !== undefined ? `${plain(target)} ` : ''}${children}${fallbackProps.context ? `\n${plain(fallbackProps.context)}` : ''}`;
169
- if (syntax.type !== 'Tag' && !node.fallback.endsWith('\n')) {
170
- node.fallback += '\n';
171
- }
172
- }
175
+ node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
176
+ node.fallback = uiNodeFallback(syntax.type, fallbackProps, node.children.map((child) => child.fallback).join(''));
173
177
  if (!node.fallback.trim()) {
174
178
  node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
175
179
  }
@@ -178,14 +182,27 @@ export function compileUiBlock(source, options) {
178
182
  try {
179
183
  const syntax = parseUiSyntax(source, options.id, options.final, budget);
180
184
  block.diagnostics = syntax.diagnostics;
181
- for (const [name, expression] of Object.entries(syntax.declarations)) {
185
+ 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
186
  try {
183
187
  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.');
188
+ if (expression.args.length !== 2 || !isConstant(expression.args[0]) || expression.args[1].kind !== 'object') {
189
+ throw new UiFailure('invalid_query', '@Query needs a literal source and an argument record.');
186
190
  }
187
191
  const querySource = evaluateUiExpression(expression.args[0], {}, budget);
188
- const args = evaluateUiExpression(expression.args[1], {}, budget);
192
+ const argument = expression.args[1];
193
+ const references = (part) => {
194
+ if (!part || typeof part !== 'object') {
195
+ return;
196
+ }
197
+ if ('kind' in part && part.kind === 'reference' && 'name' in part && !Object.hasOwn(block.defaults, String(part.name))) {
198
+ throw new UiFailure('invalid_query', 'Query arguments may reference only declared local state.');
199
+ }
200
+ for (const value of Object.values(part)) {
201
+ references(value);
202
+ }
203
+ };
204
+ references(argument);
205
+ const args = evaluateUiExpression(argument, block.defaults, budget);
189
206
  if (typeof querySource !== 'string' || !args || Array.isArray(args) || typeof args !== 'object') {
190
207
  throw new UiFailure('invalid_query', 'Invalid query source or argument record.');
191
208
  }
@@ -196,7 +213,10 @@ export function compileUiBlock(source, options) {
196
213
  if (!checked.success) {
197
214
  throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
198
215
  }
199
- block.queries[name] = { source: querySource, args };
216
+ if (Object.keys(block.queries).length >= UI_HOST_LIMITS.queries) {
217
+ throw new UiFailure('budget_exceeded', `A block may declare at most ${UI_HOST_LIMITS.queries} queries.`);
218
+ }
219
+ block.queries[name] = { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
200
220
  }
201
221
  else {
202
222
  if (!isConstant(expression)) {
@@ -212,7 +232,7 @@ export function compileUiBlock(source, options) {
212
232
  diagnose(error, 0, source.length);
213
233
  }
214
234
  }
215
- block.nodes = syntax.nodes.map((node) => compile(node));
235
+ block.nodes = syntax.nodes.map((node) => compile(node, 0));
216
236
  block.fallback = block.nodes
217
237
  .map((node) => node.fallback)
218
238
  .join('')
@@ -230,37 +250,123 @@ export function compileUiBlock(source, options) {
230
250
  }
231
251
  return block;
232
252
  }
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);
253
+ export class UiCompiler {
254
+ options;
255
+ cache = new Map();
256
+ constructor(options) {
257
+ this.options = options;
258
+ }
259
+ compile(text, update = {}) {
260
+ const blocks = [];
261
+ const next = new Map();
262
+ let fence = null;
263
+ let position = 0;
264
+ let characters = 0;
265
+ let nodes = 0;
266
+ let exhausted = false;
267
+ const now = this.options.now ?? Date.now;
268
+ const language = this.options.fenceLanguage ?? UI_FENCE_LANGUAGE;
269
+ const diagnostics = this.options.limits?.diagnostics ?? UI_LIMITS.diagnostics;
270
+ const started = now();
271
+ const nodeCount = (items) => items.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
272
+ const rejected = (start, codeStart, codeEnd, end, complete) => ({
273
+ id: `${this.options.id}:ui:${start}`,
274
+ catalogVersion: UI_CATALOG_VERSION,
275
+ start,
276
+ end,
277
+ complete,
278
+ defaults: {},
279
+ queries: {},
280
+ nodes: [],
281
+ fallback: text.slice(codeStart, Math.min(codeEnd, codeStart + 4096)),
282
+ diagnostics: [uiDiagnostic(new UiFailure('budget_exceeded', 'This reply exceeded its UI budget; remaining fences stay text.'), start, end)]
283
+ });
284
+ const append = (start, codeStart, codeEnd, end, closed) => {
285
+ if (exhausted) {
286
+ return;
287
+ }
288
+ if (blocks.length >= UI_REPLY_LIMITS.blocks) {
289
+ const last = blocks.at(-1);
290
+ blocks[blocks.length - 1] = {
291
+ ...last,
292
+ diagnostics: [
293
+ ...last.diagnostics.slice(0, diagnostics - 1),
294
+ uiDiagnostic(new UiFailure('budget_exceeded', 'Further UI fences stay text because this reply has too many blocks.'), start, end)
295
+ ]
296
+ };
297
+ exhausted = true;
298
+ return;
299
+ }
300
+ const complete = closed || update.final === true;
301
+ characters += codeEnd - codeStart;
302
+ if (characters > UI_REPLY_LIMITS.characters || now() - started > UI_REPLY_LIMITS.milliseconds) {
303
+ blocks.push(rejected(start, codeStart, codeEnd, end, complete));
304
+ exhausted = true;
305
+ return;
306
+ }
307
+ const source = text.slice(codeStart, codeEnd);
308
+ const cached = this.cache.get(start);
309
+ const unchanged = !update.final &&
310
+ cached?.source === source &&
311
+ cached.block.end === end &&
312
+ cached.block.complete === complete &&
313
+ cached.latestAttachment === update.latestAttachment;
314
+ const block = unchanged
315
+ ? cached.block
316
+ : compileUiBlock(source, {
317
+ ...this.options,
318
+ ...update,
319
+ id: `${this.options.id}:ui:${start}`,
320
+ final: complete
321
+ });
322
+ nodes += nodeCount(block.nodes);
323
+ if (nodes > UI_REPLY_LIMITS.nodes) {
324
+ blocks.push(rejected(start, codeStart, codeEnd, end, complete));
325
+ exhausted = true;
326
+ return;
254
327
  }
255
- fence = null;
328
+ block.start = start;
329
+ block.end = end;
330
+ blocks.push(block);
331
+ next.set(start, { source, block, latestAttachment: update.latestAttachment });
332
+ };
333
+ while (position < text.length && !exhausted) {
334
+ const newline = text.indexOf('\n', position);
335
+ const end = newline === -1 ? text.length : newline + 1;
336
+ const line = text.slice(position, end);
337
+ const opening = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)/.exec(line);
338
+ if (!fence && opening) {
339
+ fence = {
340
+ marker: opening[1][0],
341
+ length: opening[1].length,
342
+ start: position,
343
+ codeStart: position + line.length,
344
+ ui: opening[2].trim() === language
345
+ };
346
+ }
347
+ else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
348
+ if (fence.ui) {
349
+ append(fence.start, fence.codeStart, position, position + line.length, true);
350
+ }
351
+ fence = null;
352
+ }
353
+ position = end;
256
354
  }
257
- position += line.length;
355
+ if (fence?.ui) {
356
+ append(fence.start, fence.codeStart, text.length, text.length, false);
357
+ }
358
+ this.cache = next;
359
+ return blocks;
258
360
  }
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);
361
+ clear() {
362
+ this.cache.clear();
264
363
  }
265
- return blocks;
364
+ }
365
+ /* A cheap test for whether `text` opens a UI fence at all, before a compiler is worth creating. */
366
+ export function uiHasFence(text, fenceLanguage = UI_FENCE_LANGUAGE) {
367
+ const language = fenceLanguage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
368
+ return new RegExp(`^ {0,3}(?:\`{3,}|~{3,})[ \\t]*${language}[ \\t]*\\r?$`, 'm').test(text);
369
+ }
370
+ export function compileUi(text, options) {
371
+ return new UiCompiler(options).compile(text, options);
266
372
  }
@@ -36,5 +36,6 @@ export type UiExpression = {
36
36
  args: UiExpression[];
37
37
  };
38
38
  export declare function parseUiExpression(source: string, budget?: UiBudget): UiExpression;
39
+ export declare function sameUiValue(first: UiValue, second: UiValue): boolean;
39
40
  export declare function copyUiValue(value: unknown, budget?: UiBudget, depth?: number): UiValue;
40
41
  export declare function evaluateUiExpression(expression: UiExpression, variables?: Readonly<Record<string, UiValue>>, budget?: UiBudget): UiValue;