@adecore/intelligent-ui 0.21.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
- 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
+ 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 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
-
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,13 +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
6
  export declare const UI_REPLY_LIMITS: {
7
7
  readonly blocks: 16;
8
8
  readonly characters: 262144;
9
9
  readonly nodes: 2048;
10
10
  readonly milliseconds: 60;
11
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;
12
18
  export interface UiNode {
13
19
  id: string;
14
20
  type: string;
@@ -46,6 +52,7 @@ export interface UiCompileOptions {
46
52
  limits?: Partial<UiLimits>;
47
53
  latestAttachment?: string;
48
54
  querySchemas?: Readonly<Record<string, z.ZodType>>;
55
+ fenceLanguage?: string;
49
56
  now?: () => number;
50
57
  }
51
58
  export declare function compileUiBlock(source: string, options: UiCompileOptions): UiBlock;
@@ -58,4 +65,5 @@ export declare class UiCompiler {
58
65
  compile(text: string, update?: UiCompileUpdate): UiBlock[];
59
66
  clear(): void;
60
67
  }
68
+ export declare function uiHasFence(text: string, fenceLanguage?: string): boolean;
61
69
  export declare function compileUi(text: string, options: UiCompileOptions): UiBlock[];
package/dist/compiler.js CHANGED
@@ -1,10 +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';
7
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
+ }
8
23
  function isConstant(expression) {
9
24
  switch (expression.kind) {
10
25
  case 'literal':
@@ -19,9 +34,6 @@ function isConstant(expression) {
19
34
  return false;
20
35
  }
21
36
  }
22
- function plain(value) {
23
- return value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
24
- }
25
37
  export function compileUiBlock(source, options) {
26
38
  const budget = new UiBudget(options.limits, options.now);
27
39
  const block = {
@@ -41,8 +53,16 @@ export function compileUiBlock(source, options) {
41
53
  block.diagnostics.push(uiDiagnostic(error, start, end, nodeId));
42
54
  }
43
55
  };
44
- const compile = (syntax, parent) => {
45
- 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);
46
66
  const node = {
47
67
  id: syntax.id,
48
68
  type: syntax.type,
@@ -61,7 +81,7 @@ export function compileUiBlock(source, options) {
61
81
  if (syntax.expression) {
62
82
  node.expressions.text = syntax.expression;
63
83
  try {
64
- node.fallback = plain(evaluateUiExpression(syntax.expression, block.defaults, budget));
84
+ node.fallback = uiPlainText(evaluateUiExpression(syntax.expression, block.defaults, budget));
65
85
  }
66
86
  catch (error) {
67
87
  if (error instanceof UiFailure && error.code === 'budget_exceeded') {
@@ -91,8 +111,13 @@ export function compileUiBlock(source, options) {
91
111
  throw new UiFailure('invalid_child', `${syntax.type} is not a child of ${parent}.`);
92
112
  }
93
113
  }
114
+ const actionProp = uiActionProp(syntax.type);
94
115
  for (const [key, expression] of Object.entries(syntax.props)) {
95
116
  safeKey(key);
117
+ if (key === actionProp) {
118
+ node.expressions[key] = checkedAction(expression);
119
+ continue;
120
+ }
96
121
  if (!Object.hasOwn(entry.schema.shape, key)) {
97
122
  diagnose(new UiFailure('refused_prop', `${syntax.type} does not accept ${key}.`), syntax.start, syntax.end, syntax.id);
98
123
  continue;
@@ -102,11 +127,7 @@ export function compileUiBlock(source, options) {
102
127
  }
103
128
  else {
104
129
  node.expressions[key] = expression;
105
- if ('binding' in entry &&
106
- entry.binding === key &&
107
- expression.kind === 'reference' &&
108
- Object.hasOwn(block.defaults, expression.name) &&
109
- !Object.hasOwn(block.queries, expression.name)) {
130
+ if ('binding' in entry && entry.binding === key && expression.kind === 'reference' && uiLocalState(block)(expression.name)) {
110
131
  node.bindings[key] = expression.name;
111
132
  }
112
133
  }
@@ -115,10 +136,16 @@ export function compileUiBlock(source, options) {
115
136
  delete node.props.generated;
116
137
  node.props.attachment = options.latestAttachment;
117
138
  }
139
+ if (actionProp !== undefined && syntax.complete && !node.expressions[actionProp]) {
140
+ throw new UiFailure('invalid_props', `${syntax.type} needs ${actionProp}.`);
141
+ }
118
142
  const evaluated = { ...node.props };
119
143
  fallbackProps = evaluated;
120
144
  let unresolved = false;
121
145
  for (const [key, expression] of Object.entries(node.expressions)) {
146
+ if (key === actionProp) {
147
+ continue;
148
+ }
122
149
  try {
123
150
  evaluated[key] = evaluateUiExpression(expression, block.defaults, budget);
124
151
  }
@@ -145,32 +172,8 @@ export function compileUiBlock(source, options) {
145
172
  diagnose(error, syntax.start, syntax.end, syntax.id);
146
173
  }
147
174
  const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
148
- node.children = syntax.children.map((child) => compile(child, effectiveParent));
149
- const children = node.children.map((child) => child.fallback).join('');
150
- const label = fallbackProps.label ?? fallbackProps.title;
151
- const value = fallbackProps.value;
152
- const target = fallbackProps.path ?? fallbackProps.sha ?? (syntax.type === 'Node' ? fallbackProps.id : undefined);
153
- if (syntax.type === 'Stat') {
154
- node.fallback = `${plain(label ?? '')}: ${plain(value ?? '')}${fallbackProps.unit ? ` ${plain(fallbackProps.unit)}` : ''}\n`;
155
- }
156
- else if (syntax.type === 'Source') {
157
- node.fallback = `${plain(label ?? '')}: ${plain(fallbackProps.url ?? '')}\n`;
158
- }
159
- else if (syntax.type === 'Table') {
160
- node.fallback = `${plain(fallbackProps.rows ?? [])}\n`;
161
- }
162
- else if (syntax.type === 'Chart') {
163
- node.fallback = `${plain(fallbackProps.data ?? [])}\n`;
164
- }
165
- else if (syntax.type === 'Image') {
166
- node.fallback = `[Image: ${plain(fallbackProps.alt ?? fallbackProps.attachment ?? fallbackProps.generated ?? 'unavailable')}]${children}\n`;
167
- }
168
- else {
169
- node.fallback = `${label !== undefined ? `${plain(label)}: ` : ''}${target !== undefined ? `${plain(target)} ` : ''}${children}${fallbackProps.context ? `\n${plain(fallbackProps.context)}` : ''}`;
170
- if (syntax.type !== 'Tag' && !node.fallback.endsWith('\n')) {
171
- node.fallback += '\n';
172
- }
173
- }
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(''));
174
177
  if (!node.fallback.trim()) {
175
178
  node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
176
179
  }
@@ -210,8 +213,8 @@ export function compileUiBlock(source, options) {
210
213
  if (!checked.success) {
211
214
  throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
212
215
  }
213
- if (Object.keys(block.queries).length >= 8) {
214
- throw new UiFailure('budget_exceeded', 'A block may declare at most eight queries.');
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.`);
215
218
  }
216
219
  block.queries[name] = { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
217
220
  }
@@ -229,7 +232,7 @@ export function compileUiBlock(source, options) {
229
232
  diagnose(error, 0, source.length);
230
233
  }
231
234
  }
232
- block.nodes = syntax.nodes.map((node) => compile(node));
235
+ block.nodes = syntax.nodes.map((node) => compile(node, 0));
233
236
  block.fallback = block.nodes
234
237
  .map((node) => node.fallback)
235
238
  .join('')
@@ -262,6 +265,8 @@ export class UiCompiler {
262
265
  let nodes = 0;
263
266
  let exhausted = false;
264
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;
265
270
  const started = now();
266
271
  const nodeCount = (items) => items.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
267
272
  const rejected = (start, codeStart, codeEnd, end, complete) => ({
@@ -285,7 +290,7 @@ export class UiCompiler {
285
290
  blocks[blocks.length - 1] = {
286
291
  ...last,
287
292
  diagnostics: [
288
- ...last.diagnostics.slice(0, 19),
293
+ ...last.diagnostics.slice(0, diagnostics - 1),
289
294
  uiDiagnostic(new UiFailure('budget_exceeded', 'Further UI fences stay text because this reply has too many blocks.'), start, end)
290
295
  ]
291
296
  };
@@ -336,7 +341,7 @@ export class UiCompiler {
336
341
  length: opening[1].length,
337
342
  start: position,
338
343
  codeStart: position + line.length,
339
- ui: opening[2].trim() === UI_FENCE_LANGUAGE
344
+ ui: opening[2].trim() === language
340
345
  };
341
346
  }
342
347
  else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
@@ -357,6 +362,11 @@ export class UiCompiler {
357
362
  this.cache.clear();
358
363
  }
359
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
+ }
360
370
  export function compileUi(text, options) {
361
371
  return new UiCompiler(options).compile(text, options);
362
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;
@@ -230,6 +230,22 @@ class ExpressionParser {
230
230
  export function parseUiExpression(source, budget = new UiBudget()) {
231
231
  return new ExpressionParser(source, budget).parse();
232
232
  }
233
+ /* Deep equality of two values, regardless of the order their record keys were written in. */
234
+ export function sameUiValue(first, second) {
235
+ if (first === second) {
236
+ return true;
237
+ }
238
+ if (first === null || second === null || typeof first !== 'object' || typeof second !== 'object' || Array.isArray(first) !== Array.isArray(second)) {
239
+ return false;
240
+ }
241
+ if (Array.isArray(first) && Array.isArray(second)) {
242
+ return first.length === second.length && first.every((item, i) => sameUiValue(item, second[i]));
243
+ }
244
+ const left = first;
245
+ const right = second;
246
+ const keys = Object.keys(left);
247
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameUiValue(left[key], right[key]));
248
+ }
233
249
  export function copyUiValue(value, budget = new UiBudget(), depth = 0) {
234
250
  budget.depth(depth);
235
251
  if (value === null || typeof value === 'boolean') {
@@ -0,0 +1,2 @@
1
+ export declare function uiPlainText(value: unknown): string;
2
+ export declare function uiNodeFallback(type: string, props: Readonly<Record<string, unknown>>, children: string): string;
@@ -0,0 +1,25 @@
1
+ export function uiPlainText(value) {
2
+ return value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
3
+ }
4
+ /* The readable text a node leaves behind where it cannot be drawn, from its props and its children's text. */
5
+ export function uiNodeFallback(type, props, children) {
6
+ const label = props.label ?? props.title;
7
+ switch (type) {
8
+ case '$text':
9
+ return uiPlainText(props.text);
10
+ case 'Stat':
11
+ return `${uiPlainText(label)}: ${uiPlainText(props.value)}${props.unit ? ` ${uiPlainText(props.unit)}` : ''}\n`;
12
+ case 'Source':
13
+ return `${uiPlainText(label)}: ${uiPlainText(props.url)}\n`;
14
+ case 'Table':
15
+ return `${uiPlainText(props.rows ?? [])}\n`;
16
+ case 'Chart':
17
+ return `${uiPlainText(props.data ?? [])}\n`;
18
+ case 'Image':
19
+ return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
20
+ }
21
+ const target = props.path ?? props.sha ?? (type === 'Node' ? props.id : undefined);
22
+ const context = props.context ? `\n${uiPlainText(props.context)}` : '';
23
+ const text = `${label !== undefined ? `${uiPlainText(label)}: ` : ''}${target !== undefined ? `${uiPlainText(target)} ` : ''}${children}${context}`;
24
+ return type === 'Tag' || text.endsWith('\n') ? text : `${text}\n`;
25
+ }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText, type UiComponentName, type UiProps, type UiTone } from './catalog.ts';
2
2
  export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
3
- export { copyUiValue, evaluateUiExpression, parseUiExpression, type UiExpression, type UiValue } from './expression.ts';
3
+ export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue, 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, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock, type UiCompilerOptions, type UiCompileUpdate, type UiBlock, type UiCompileOptions, type UiNode, type UiQuery } from './compiler.ts';
5
+ export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock, uiHasFence, uiMayReferenceHost, type UiCompilerOptions, type UiCompileUpdate, type UiBlock, type UiCompileOptions, type UiNode, type UiQuery } from './compiler.ts';
6
6
  export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, type UiChoiceSelection, type UiBinding, type UiEvaluation, type UiViewNode } from './runtime.ts';
7
7
  export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
8
8
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
9
- export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.ts';
9
+ export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
10
10
  export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
11
11
  export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText } from './catalog.js';
2
2
  export { UI_LIMITS, UiBudget, UiFailure, safeKey } from './budget.js';
3
- export { copyUiValue, evaluateUiExpression, parseUiExpression } from './expression.js';
3
+ export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue } from './expression.js';
4
4
  export { parseUiSyntax, uiDiagnostic } from './syntax.js';
5
- export { UI_FENCE_LANGUAGE, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock } from './compiler.js';
5
+ export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock, uiHasFence, uiMayReferenceHost } from './compiler.js';
6
6
  export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice } from './runtime.js';
7
7
  export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.js';
8
8
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.js';
@@ -0,0 +1,5 @@
1
+ import type { UiBlock, UiNode } from './compiler.ts';
2
+ import { type UiValue } from './expression.ts';
3
+ import type { UiViewNode } from './runtime.ts';
4
+ export declare function uiCompiledNodes(nodes: readonly UiNode[], into?: Map<string, UiNode>): Map<string, UiNode>;
5
+ export declare function uiVisibleInputs(block: UiBlock, nodes: readonly UiViewNode[], scope: Readonly<Record<string, UiValue>>): Set<string>;
package/dist/inputs.js ADDED
@@ -0,0 +1,69 @@
1
+ import { uiActionProp, uiActionTarget, uiLocalState } from './actions.js';
2
+ import { evaluateUiExpression, sameUiValue } from './expression.js';
3
+ export function uiCompiledNodes(nodes, into = new Map()) {
4
+ for (const node of nodes) {
5
+ into.set(node.id, node);
6
+ uiCompiledNodes(node.children, into);
7
+ }
8
+ return into;
9
+ }
10
+ /* Whether a control's value is one its own visible options offer. */
11
+ function offered(node) {
12
+ if (node.type === 'Segmented') {
13
+ return node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
14
+ }
15
+ if (node.type === 'Checklist') {
16
+ const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
17
+ return node.props.value.every((value) => options.includes(value));
18
+ }
19
+ return true;
20
+ }
21
+ /*
22
+ * The value pressing a Button sets its target to, or undefined when it sets none. A Button's @Set takes
23
+ * a literal only, so the value never depends on the state it was pressed in.
24
+ */
25
+ function actionValue(action, block) {
26
+ try {
27
+ const target = uiActionTarget(action, uiLocalState(block));
28
+ if (target === null || action.kind !== 'call') {
29
+ return undefined;
30
+ }
31
+ return { target, value: action.name === 'Set' ? evaluateUiExpression(action.args[1], {}) : block.defaults[target] };
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /*
38
+ * The variables whose current value a visible, complete control of the evaluated tree accounts for: a bound
39
+ * control holding it within what it offers, or an enabled Button that sets exactly that value. A submitted
40
+ * value outside this set, and unequal to its default, is one no person could have produced on the screen.
41
+ */
42
+ export function uiVisibleInputs(block, nodes, scope) {
43
+ const written = uiCompiledNodes(block.nodes);
44
+ const inputs = new Set();
45
+ const visit = (views) => {
46
+ for (const node of views) {
47
+ if (node.error || !node.complete) {
48
+ continue;
49
+ }
50
+ const source = written.get(node.sourceId ?? node.id);
51
+ if (source && offered(node)) {
52
+ for (const prop of Object.keys(node.bindings)) {
53
+ if (source.bindings[prop]) {
54
+ inputs.add(source.bindings[prop]);
55
+ }
56
+ }
57
+ }
58
+ const actionProp = uiActionProp(node.type);
59
+ const action = source && actionProp ? source.expressions[actionProp] : undefined;
60
+ const pressed = action && node.props.disabled !== true ? actionValue(action, block) : undefined;
61
+ if (pressed && sameUiValue(pressed.value, scope[pressed.target])) {
62
+ inputs.add(pressed.target);
63
+ }
64
+ visit(node.children);
65
+ }
66
+ };
67
+ visit(nodes);
68
+ return inputs;
69
+ }
package/dist/links.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import type { UiLimits } from './budget.ts';
2
3
  import type { UiBlock } from './compiler.ts';
3
4
  export declare const UiLinkTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
4
5
  type: z.ZodLiteral<"File">;
@@ -41,7 +42,8 @@ export declare const UiLinkResolutionSchema: z.ZodObject<{
41
42
  conflicted: z.ZodOptional<z.ZodBoolean>;
42
43
  relativePath: z.ZodOptional<z.ZodString>;
43
44
  label: z.ZodOptional<z.ZodString>;
45
+ code: z.ZodOptional<z.ZodString>;
44
46
  reason: z.ZodOptional<z.ZodString>;
45
47
  }, z.core.$strip>;
46
48
  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>;
49
+ export declare function uiLinkTargets(block: UiBlock, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>, limits?: Partial<UiLimits>): Record<string, UiLinkTarget>;