@adecore/intelligent-ui 0.22.0 → 0.22.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/dist/catalog.d.ts CHANGED
@@ -34,8 +34,8 @@ export declare const UI_GROUPS: {
34
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
- readonly description: 'Ask the person to pick a next step.';
38
- readonly rules: 'Give a short label and precise visible context. A Choice sends once after streaming ends.';
37
+ readonly description: 'Offer optional next steps after a finished answer.';
38
+ readonly rules: 'Never ask for information or a decision needed before acting: use your own question tool if you have one, otherwise ask in prose. Give a short label and precise visible context. A Choice sends once after streaming ends.';
39
39
  };
40
40
  };
41
41
  export declare const UI_CATALOG: {
@@ -355,7 +355,7 @@ export declare const UI_CATALOG: {
355
355
  };
356
356
  readonly Choices: {
357
357
  readonly group: 'choices';
358
- readonly description: 'Visible next steps.';
358
+ readonly description: 'Optional next steps after a finished answer.';
359
359
  readonly schema: z.ZodObject<{}, z.core.$strict>;
360
360
  readonly children: readonly ['Choice'];
361
361
  };
package/dist/catalog.js CHANGED
@@ -23,8 +23,8 @@ export const UI_GROUPS = {
23
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
24
  },
25
25
  choices: {
26
- description: 'Ask the person to pick a next step.',
27
- rules: 'Give a short label and precise visible context. A Choice sends once after streaming ends.'
26
+ description: 'Offer optional next steps after a finished answer.',
27
+ rules: 'Never ask for information or a decision needed before acting: use your own question tool if you have one, otherwise ask in prose. Give a short label and precise visible context. A Choice sends once after streaming ends.'
28
28
  }
29
29
  };
30
30
  export const UI_CATALOG = {
@@ -131,7 +131,7 @@ export const UI_CATALOG = {
131
131
  description: 'Repeat children over local data.',
132
132
  schema: z.strictObject({ items: z.array(z.unknown()).max(1000), as: name.regex(/^[A-Za-z_][A-Za-z_\d]*$/) })
133
133
  },
134
- Choices: { group: 'choices', description: 'Visible next steps.', schema: empty, children: ['Choice'] },
134
+ Choices: { group: 'choices', description: 'Optional next steps after a finished answer.', schema: empty, children: ['Choice'] },
135
135
  Choice: {
136
136
  group: 'choices',
137
137
  description: 'Send its label and visible context once.',
package/dist/compiler.js CHANGED
@@ -78,6 +78,23 @@ function isConstant(expression) {
78
78
  return false;
79
79
  }
80
80
  }
81
+ const CHART_SERIES = 6;
82
+ /* The keys besides `label` that hold a finite number in some row of a chart's data. */
83
+ function chartSeries(data) {
84
+ const keys = new Set();
85
+ for (const row of Array.isArray(data) ? data : []) {
86
+ for (const [key, value] of Object.entries((row ?? {}))) {
87
+ if (key !== 'label' && typeof value === 'number' && Number.isFinite(value)) {
88
+ keys.add(key);
89
+ }
90
+ }
91
+ }
92
+ return keys.size;
93
+ }
94
+ /* Whether a child can give a Choice words: a component, an expression, or text that is more than whitespace. */
95
+ function isLabelSource(child) {
96
+ return child.type !== '$text' || child.expression !== undefined || (child.text ?? '').trim() !== '';
97
+ }
81
98
  export function compileUiBlock(source, options) {
82
99
  const budget = new UiBudget(options.limits, options.now);
83
100
  const block = {
@@ -203,15 +220,35 @@ export function compileUiBlock(source, options) {
203
220
  throw new UiFailure('invalid_props', parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '));
204
221
  }
205
222
  }
223
+ // The catalog promises six series, one per chart color; more still reach the values a person can open.
224
+ if (!unresolved && syntax.complete && syntax.type === 'Chart' && chartSeries(evaluated.data) > CHART_SERIES) {
225
+ diagnose(new UiFailure('too_many_series', `Chart draws up to ${CHART_SERIES} numeric series; the rest are only in its values.`), syntax.start, syntax.end, syntax.id);
226
+ }
227
+ // A Choice sends its label, so one without a label can never send; whether it closed is only known once final.
228
+ if (syntax.type === 'Choice' && syntax.complete && !syntax.children.some(isLabelSource)) {
229
+ throw new UiFailure('invalid_props', 'A choice needs a visible label.');
230
+ }
206
231
  }
207
232
  catch (error) {
208
233
  rethrowBudget(error);
209
234
  node.error = error instanceof UiFailure ? error.code : 'invalid_props';
210
235
  diagnose(error, syntax.start, syntax.end, syntax.id);
211
236
  }
237
+ // The parser already diagnosed both, so they only decide how the node draws.
238
+ if (node.error === undefined && syntax.invalid) {
239
+ node.error = 'invalid_syntax';
240
+ }
241
+ if (node.error === undefined && syntax.type === 'Choice' && options.final && !syntax.complete) {
242
+ node.error = 'unclosed_tag';
243
+ }
212
244
  const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
213
245
  node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
214
- node.fallback = uiNodeFallback(syntax.type, fallbackProps, node.children.map((child) => child.fallback).join(''));
246
+ const childText = node.children.map((child) => child.fallback).join('');
247
+ node.fallback = uiNodeFallback(syntax.type, fallbackProps, childText);
248
+ // A part drawn as its text keeps the words written inside it, also where its kind reads only props.
249
+ if (node.error !== undefined && childText.trim() !== '' && !node.fallback.includes(childText.trim())) {
250
+ node.fallback += childText;
251
+ }
215
252
  if (!node.fallback.trim()) {
216
253
  node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
217
254
  }
package/dist/fallback.js CHANGED
@@ -1,24 +1,48 @@
1
1
  export function uiPlainText(value) {
2
2
  return value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
3
3
  }
4
- /* The readable text a node leaves behind where it cannot be drawn, from its props and its children's text. */
4
+ function given(value) {
5
+ return value !== undefined && value !== null && value !== '';
6
+ }
7
+ /*
8
+ * The readable text a node leaves behind where it cannot be drawn, from its props and its children's text.
9
+ * Another agent reads a reply through it too, so every prop that says something (a state, a detail, a
10
+ * badge, a unit) is in it; only props that style a node or drive local state are left out.
11
+ */
5
12
  export function uiNodeFallback(type, props, children) {
6
13
  const label = props.label ?? props.title;
7
14
  switch (type) {
8
15
  case '$text':
9
16
  return uiPlainText(props.text);
10
- case 'Stat':
11
- return `${uiPlainText(label)}: ${uiPlainText(props.value)}${props.unit ? ` ${uiPlainText(props.unit)}` : ''}\n`;
17
+ case 'Summary':
18
+ return `${children.trimEnd()}${given(props.badge) ? ` (${uiPlainText(props.badge)})` : ''}\n`;
19
+ case 'Step':
20
+ return `${children.trimEnd()}${given(props.state) ? ` (${uiPlainText(props.state)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
21
+ case 'Progress':
22
+ return given(props.value) ? `${children.trimEnd()}: ${uiPlainText(props.value)} of ${uiPlainText(props.max ?? 100)}\n` : `${children.trimEnd()}\n`;
23
+ case 'Stat': {
24
+ const unit = given(props.unit) ? ` ${uiPlainText(props.unit)}` : '';
25
+ const previous = given(props.previous) ? ` (was ${uiPlainText(props.previous)}${unit})` : '';
26
+ return `${uiPlainText(label)}: ${uiPlainText(props.value)}${unit}${previous}\n`;
27
+ }
12
28
  case 'Source':
13
29
  return `${uiPlainText(label)}: ${uiPlainText(props.url)}\n`;
14
- case 'Table':
15
- return `${uiPlainText(props.rows ?? [])}\n`;
30
+ case 'Column':
31
+ return `${uiPlainText(props.title ?? props.key)}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
32
+ case 'Table': {
33
+ const columns = children
34
+ .split('\n')
35
+ .map((column) => column.trim())
36
+ .filter(Boolean);
37
+ return `${columns.length > 0 ? `Columns: ${columns.join(', ')}\n` : ''}${uiPlainText(props.rows ?? [])}\n`;
38
+ }
16
39
  case 'Chart':
17
- return `${uiPlainText(props.data ?? [])}\n`;
40
+ return `${uiPlainText(props.data ?? [])}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
18
41
  case 'Image':
19
42
  return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
20
43
  }
21
- const target = props.path ?? props.sha ?? (type === 'Node' ? props.id : undefined);
44
+ const path = given(props.path) && given(props.line) ? `${uiPlainText(props.path)}:${uiPlainText(props.line)}` : props.path;
45
+ const target = path ?? props.sha ?? (type === 'Node' ? props.id : undefined);
22
46
  const context = props.context ? `\n${uiPlainText(props.context)}` : '';
23
47
  const text = `${label !== undefined ? `${uiPlainText(label)}: ` : ''}${target !== undefined ? `${uiPlainText(target)} ` : ''}${children}${context}`;
24
48
  return type === 'Tag' || text.endsWith('\n') ? text : `${text}\n`;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget
3
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
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
- export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, type UiChoiceSelection, type UiBinding, type UiEvaluation, type UiViewNode } from './runtime.ts';
6
+ export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, uiChoiceLabel, 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
9
  export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { UI_LIMITS, UiBudget, UiFailure, safeKey } from './budget.js';
3
3
  export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue } from './expression.js';
4
4
  export { parseUiSyntax, uiDiagnostic } from './syntax.js';
5
5
  export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock, uiHasFence, uiMayReferenceHost } from './compiler.js';
6
- export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice } from './runtime.js';
6
+ export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, uiChoiceLabel } from './runtime.js';
7
7
  export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.js';
8
8
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.js';
9
9
  export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.js';
package/dist/runtime.d.ts CHANGED
@@ -48,4 +48,5 @@ export interface UiChoiceSelection {
48
48
  values: Record<string, UiValue>;
49
49
  }
50
50
  export declare function uiInputValues(block: UiBlock, state: UiState): Record<string, UiValue>;
51
+ export declare function uiChoiceLabel(choice: UiViewNode): string | null;
51
52
  export declare function resolveUiChoice(block: UiBlock, choiceId: string, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>, limits?: Partial<UiLimits>): UiChoiceSelection;
package/dist/runtime.js CHANGED
@@ -262,6 +262,22 @@ export function uiInputValues(block, state) {
262
262
  return values;
263
263
  }
264
264
  /* The host supplies its stored block; client data can change only declared inputs and Button targets. */
265
+ function hasError(nodes) {
266
+ return nodes.some((node) => node.error !== undefined || hasError(node.children));
267
+ }
268
+ function labelText(nodes) {
269
+ return nodes.map((node) => (node.type === '$text' ? String(node.props.text ?? '') : labelText(node.children))).join('');
270
+ }
271
+ /*
272
+ * The label an evaluated Choice sends, on one line, or null when it can never send: it failed, never
273
+ * closed, part of its label could not be read, or the label is empty. A page draws no open row for null.
274
+ */
275
+ export function uiChoiceLabel(choice) {
276
+ if (choice.error !== undefined || !choice.complete || hasError(choice.children)) {
277
+ return null;
278
+ }
279
+ return labelText(choice.children).replace(/\s+/g, ' ').trim() || null;
280
+ }
265
281
  export function resolveUiChoice(block, choiceId, input = {}, queries = {}, limits = {}) {
266
282
  if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
267
283
  throw new UiFailure('refused_choice', 'Only a completed supported block can send a choice.');
@@ -311,14 +327,9 @@ export function resolveUiChoice(block, choiceId, input = {}, queries = {}, limit
311
327
  if (!choice) {
312
328
  throw new UiFailure('refused_choice', 'This choice is not available in the current block.');
313
329
  }
314
- const text = (nodes) => nodes.map((node) => (node.type === '$text' ? String(node.props.text ?? '') : text(node.children))).join('');
315
- const hasError = (nodes) => nodes.some((node) => node.error !== undefined || hasError(node.children));
316
- if (hasError(choice.children)) {
317
- throw new UiFailure('refused_choice', 'The choice label could not be fully read.');
318
- }
319
- const label = text(choice.children).replace(/\s+/g, ' ').trim();
320
- if (!label) {
321
- throw new UiFailure('refused_choice', 'A choice needs a visible label.');
330
+ const label = uiChoiceLabel(choice);
331
+ if (label === null) {
332
+ throw new UiFailure('refused_choice', hasError(choice.children) ? 'The choice label could not be fully read.' : 'A choice needs a visible label.');
322
333
  }
323
334
  return {
324
335
  label,
package/dist/syntax.d.ts CHANGED
@@ -15,6 +15,7 @@ export interface UiSyntaxNode {
15
15
  text?: string;
16
16
  expression?: UiExpression;
17
17
  complete: boolean;
18
+ invalid?: boolean;
18
19
  start: number;
19
20
  end: number;
20
21
  }
package/dist/syntax.js CHANGED
@@ -209,82 +209,146 @@ class UiParser {
209
209
  node.end = this.position;
210
210
  return;
211
211
  }
212
+ // A tag that lost a prop and has no closing tag anywhere after it was meant to close itself.
213
+ if (node.invalid && this.final && !this.closesLater(node.type)) {
214
+ node.complete = true;
215
+ node.end = this.position;
216
+ return;
217
+ }
212
218
  this.stack.push(node);
213
219
  return;
214
220
  }
215
- const prop = /^[A-Za-z_][A-Za-z_\d]*/.exec(this.source.slice(this.position));
216
- if (!prop) {
217
- if (this.position === this.source.length && !this.final) {
221
+ const propStart = this.position;
222
+ try {
223
+ if (!this.prop(node)) {
218
224
  return;
219
225
  }
220
- throw new UiFailure('invalid_prop', 'Expected a named prop.');
221
226
  }
222
- const name = safeKey(prop[0]);
223
- this.position += name.length;
224
- this.whitespace();
225
- if (!this.finish('=')) {
226
- return;
227
+ catch (error) {
228
+ if (error instanceof UiFailure && error.code === 'budget_exceeded') {
229
+ throw error;
230
+ }
231
+ this.diagnose(error, propStart, node.id);
232
+ node.invalid = true;
233
+ this.skipToken();
227
234
  }
228
- this.whitespace();
229
- let value;
230
- if (this.source[this.position] === '{') {
231
- const body = this.balanced();
232
- if (!body.complete) {
233
- return;
235
+ }
236
+ node.end = this.position;
237
+ if (this.final) {
238
+ this.diagnose(new UiFailure('unclosed_tag', `The ${node.type} tag is incomplete.`), start, node.id);
239
+ }
240
+ }
241
+ /* Reads one prop into the node; false while a streaming prop waits for more text. */
242
+ prop(node) {
243
+ // At the end of the text the tag waits while it streams, and is reported incomplete once final.
244
+ if (this.position === this.source.length) {
245
+ return false;
246
+ }
247
+ const prop = /^[A-Za-z_][A-Za-z_\d]*/.exec(this.source.slice(this.position));
248
+ if (!prop) {
249
+ throw new UiFailure('invalid_prop', 'Expected a named prop.');
250
+ }
251
+ const name = safeKey(prop[0]);
252
+ this.position += name.length;
253
+ this.whitespace();
254
+ if (this.position === this.source.length && !this.final) {
255
+ return false;
256
+ }
257
+ if (this.source[this.position] !== '=') {
258
+ // A bare name is true, as in JSX.
259
+ if (Object.hasOwn(node.props, name)) {
260
+ throw new UiFailure('duplicate_prop', `Duplicate prop ${name}.`);
261
+ }
262
+ node.props[name] = parseUiExpression('true', this.budget);
263
+ return true;
264
+ }
265
+ this.position += 1;
266
+ this.whitespace();
267
+ let value;
268
+ if (this.source[this.position] === '{') {
269
+ const body = this.balanced();
270
+ if (!body.complete) {
271
+ return false;
272
+ }
273
+ value = body.value;
274
+ }
275
+ else {
276
+ const quote = this.source[this.position];
277
+ if (quote !== '"' && quote !== "'") {
278
+ if (!this.final && this.position === this.source.length) {
279
+ return false;
234
280
  }
235
- value = body.value;
281
+ throw new UiFailure('invalid_prop', 'Use a quoted string or a braced expression.');
236
282
  }
237
- else {
238
- const quote = this.source[this.position];
239
- if (quote !== '"' && quote !== "'") {
240
- if (!this.final && this.position === this.source.length) {
241
- return;
242
- }
243
- throw new UiFailure('invalid_prop', 'Use a quoted string or a braced expression.');
283
+ const valueStart = this.position++;
284
+ let escaped = false;
285
+ let closed = false;
286
+ while (this.position < this.source.length) {
287
+ this.budget.step();
288
+ const character = this.source[this.position++];
289
+ if (escaped) {
290
+ escaped = false;
244
291
  }
245
- const valueStart = this.position++;
246
- let escaped = false;
247
- let closed = false;
248
- while (this.position < this.source.length) {
249
- this.budget.step();
250
- const character = this.source[this.position++];
251
- if (escaped) {
252
- escaped = false;
253
- }
254
- else if (character === '\\') {
255
- escaped = true;
256
- }
257
- else if (character === quote) {
258
- closed = true;
259
- break;
260
- }
292
+ else if (character === '\\') {
293
+ escaped = true;
261
294
  }
262
- if (!closed && !this.final) {
263
- value = this.source.slice(valueStart, this.position);
264
- if (escaped) {
265
- value = value.slice(0, -1);
266
- }
267
- try {
268
- node.props[name] = parseUiExpression(`${value}${quote}`, this.budget);
269
- }
270
- catch (error) {
271
- if (error instanceof UiFailure && error.code === 'budget_exceeded') {
272
- throw error;
273
- }
274
- }
275
- node.end = this.position;
276
- return;
295
+ else if (character === quote) {
296
+ closed = true;
297
+ break;
277
298
  }
278
- value = this.source.slice(valueStart, this.position);
279
299
  }
280
- if (Object.hasOwn(node.props, name)) {
281
- throw new UiFailure('duplicate_prop', `Duplicate prop ${name}.`);
300
+ if (!closed && !this.final) {
301
+ value = this.source.slice(valueStart, this.position);
302
+ if (escaped) {
303
+ value = value.slice(0, -1);
304
+ }
305
+ try {
306
+ node.props[name] = parseUiExpression(`${value}${quote}`, this.budget);
307
+ }
308
+ catch (error) {
309
+ if (error instanceof UiFailure && error.code === 'budget_exceeded') {
310
+ throw error;
311
+ }
312
+ }
313
+ node.end = this.position;
314
+ return false;
282
315
  }
283
- node.props[name] = parseUiExpression(value, this.budget);
316
+ value = this.source.slice(valueStart, this.position);
284
317
  }
285
- node.end = this.position;
286
- if (this.final) {
287
- this.diagnose(new UiFailure('unclosed_tag', `The ${node.type} tag is incomplete.`), start, node.id);
318
+ if (Object.hasOwn(node.props, name)) {
319
+ throw new UiFailure('duplicate_prop', `Duplicate prop ${name}.`);
320
+ }
321
+ node.props[name] = parseUiExpression(value, this.budget);
322
+ return true;
323
+ }
324
+ /* Skips a prop that could not be read, up to the next space or the end of its tag, so only that prop is lost. */
325
+ skipToken() {
326
+ let quote = '';
327
+ let depth = 0;
328
+ while (this.position < this.source.length) {
329
+ this.budget.step();
330
+ const character = this.source[this.position];
331
+ if (quote) {
332
+ if (character === '\\') {
333
+ this.position += 1;
334
+ }
335
+ else if (character === quote) {
336
+ quote = '';
337
+ }
338
+ }
339
+ else if (character === '"' || character === "'") {
340
+ quote = character;
341
+ }
342
+ else if (character === '{') {
343
+ depth += 1;
344
+ }
345
+ else if (character === '}') {
346
+ depth = Math.max(0, depth - 1);
347
+ }
348
+ else if (depth === 0 && (/\s/.test(character) || character === '>' || this.source.startsWith('/>', this.position))) {
349
+ break;
350
+ }
351
+ this.position += 1;
288
352
  }
289
353
  }
290
354
  balanced() {
@@ -322,6 +386,12 @@ class UiParser {
322
386
  }
323
387
  return { value: this.source.slice(start), complete: false };
324
388
  }
389
+ /* Whether a closing tag for this type follows, written the way `tag` accepts one: whitespace may stand before its `>`. */
390
+ closesLater(type) {
391
+ const closing = new RegExp(`</${type}\\s*>`, 'g');
392
+ closing.lastIndex = this.position;
393
+ return closing.test(this.source);
394
+ }
325
395
  whitespace() {
326
396
  while (this.position < this.source.length && /\s/.test(this.source[this.position])) {
327
397
  this.position += 1;
package/dist/text.js CHANGED
@@ -24,7 +24,7 @@ function uiExample(language) {
24
24
  ].join('\n');
25
25
  }
26
26
  export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE } = {}) {
27
- return `${uiSyntax(fenceLanguage)} Prose outside. Only named catalog props, no styling. $name = literal JSON; input value={$name}; Button action={@Set($name, literal)}, {@Reset($name)} or {@Reset()} changes local state only; braced expressions: @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show/Each control children. Choice labels and precise context stay visible and send once. Image: attachment or generated="latest", never URL/path. No code execution. State resets on reload. Catalog (? means optional props): ${uiCompactCatalog()}\nExample:\n${uiExample(fenceLanguage)}\n`;
27
+ return `${uiSyntax(fenceLanguage)} Prose outside. Only named catalog props, no styling. $name = literal JSON; input value={$name}; Button action={@Set($name, literal)}, {@Reset($name)} or {@Reset()} changes local state only; braced expressions: @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show/Each control children. Choices only offer optional next steps after a finished answer; ask for information or a decision needed before acting with your own question tool if you have one, otherwise in prose. Choice labels and precise context stay visible and send once. Image: attachment or generated="latest", never URL/path. No code execution. State resets on reload. Catalog (? means optional props): ${uiCompactCatalog()}\nExample:\n${uiExample(fenceLanguage)}\n`;
28
28
  }
29
29
  export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE } = {}) {
30
30
  const groups = Object.entries(UI_GROUPS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/intelligent-ui",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "A streaming UI catalog, compiler and bounded expression interpreter without a framework or DOM.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
package/src/catalog.ts CHANGED
@@ -27,8 +27,8 @@ export const UI_GROUPS = {
27
27
  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.'
28
28
  },
29
29
  choices: {
30
- description: 'Ask the person to pick a next step.',
31
- rules: 'Give a short label and precise visible context. A Choice sends once after streaming ends.'
30
+ description: 'Offer optional next steps after a finished answer.',
31
+ rules: 'Never ask for information or a decision needed before acting: use your own question tool if you have one, otherwise ask in prose. Give a short label and precise visible context. A Choice sends once after streaming ends.'
32
32
  }
33
33
  } as const;
34
34
 
@@ -136,7 +136,7 @@ export const UI_CATALOG = {
136
136
  description: 'Repeat children over local data.',
137
137
  schema: z.strictObject({ items: z.array(z.unknown()).max(1000), as: name.regex(/^[A-Za-z_][A-Za-z_\d]*$/) })
138
138
  },
139
- Choices: { group: 'choices', description: 'Visible next steps.', schema: empty, children: ['Choice'] },
139
+ Choices: { group: 'choices', description: 'Optional next steps after a finished answer.', schema: empty, children: ['Choice'] },
140
140
  Choice: {
141
141
  group: 'choices',
142
142
  description: 'Send its label and visible context once.',
package/src/compiler.ts CHANGED
@@ -138,6 +138,26 @@ function isConstant(expression: UiExpression): boolean {
138
138
  }
139
139
  }
140
140
 
141
+ const CHART_SERIES = 6;
142
+
143
+ /* The keys besides `label` that hold a finite number in some row of a chart's data. */
144
+ function chartSeries(data: unknown): number {
145
+ const keys = new Set<string>();
146
+ for (const row of Array.isArray(data) ? data : []) {
147
+ for (const [key, value] of Object.entries((row ?? {}) as Record<string, unknown>)) {
148
+ if (key !== 'label' && typeof value === 'number' && Number.isFinite(value)) {
149
+ keys.add(key);
150
+ }
151
+ }
152
+ }
153
+ return keys.size;
154
+ }
155
+
156
+ /* Whether a child can give a Choice words: a component, an expression, or text that is more than whitespace. */
157
+ function isLabelSource(child: UiSyntaxNode): boolean {
158
+ return child.type !== '$text' || child.expression !== undefined || (child.text ?? '').trim() !== '';
159
+ }
160
+
141
161
  export function compileUiBlock(source: string, options: UiCompileOptions): UiBlock {
142
162
  const budget = new UiBudget(options.limits, options.now);
143
163
  const block: UiBlock = {
@@ -261,14 +281,39 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
261
281
  throw new UiFailure('invalid_props', parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '));
262
282
  }
263
283
  }
284
+ // The catalog promises six series, one per chart color; more still reach the values a person can open.
285
+ if (!unresolved && syntax.complete && syntax.type === 'Chart' && chartSeries(evaluated.data) > CHART_SERIES) {
286
+ diagnose(
287
+ new UiFailure('too_many_series', `Chart draws up to ${CHART_SERIES} numeric series; the rest are only in its values.`),
288
+ syntax.start,
289
+ syntax.end,
290
+ syntax.id
291
+ );
292
+ }
293
+ // A Choice sends its label, so one without a label can never send; whether it closed is only known once final.
294
+ if (syntax.type === 'Choice' && syntax.complete && !syntax.children.some(isLabelSource)) {
295
+ throw new UiFailure('invalid_props', 'A choice needs a visible label.');
296
+ }
264
297
  } catch (error) {
265
298
  rethrowBudget(error);
266
299
  node.error = error instanceof UiFailure ? error.code : 'invalid_props';
267
300
  diagnose(error, syntax.start, syntax.end, syntax.id);
268
301
  }
302
+ // The parser already diagnosed both, so they only decide how the node draws.
303
+ if (node.error === undefined && syntax.invalid) {
304
+ node.error = 'invalid_syntax';
305
+ }
306
+ if (node.error === undefined && syntax.type === 'Choice' && options.final && !syntax.complete) {
307
+ node.error = 'unclosed_tag';
308
+ }
269
309
  const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
270
310
  node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
271
- node.fallback = uiNodeFallback(syntax.type, fallbackProps, node.children.map((child) => child.fallback).join(''));
311
+ const childText = node.children.map((child) => child.fallback).join('');
312
+ node.fallback = uiNodeFallback(syntax.type, fallbackProps, childText);
313
+ // A part drawn as its text keeps the words written inside it, also where its kind reads only props.
314
+ if (node.error !== undefined && childText.trim() !== '' && !node.fallback.includes(childText.trim())) {
315
+ node.fallback += childText;
316
+ }
272
317
  if (!node.fallback.trim()) {
273
318
  node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
274
319
  }
package/src/fallback.ts CHANGED
@@ -2,24 +2,49 @@ export function uiPlainText(value: unknown): string {
2
2
  return value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
3
3
  }
4
4
 
5
- /* The readable text a node leaves behind where it cannot be drawn, from its props and its children's text. */
5
+ function given(value: unknown): boolean {
6
+ return value !== undefined && value !== null && value !== '';
7
+ }
8
+
9
+ /*
10
+ * The readable text a node leaves behind where it cannot be drawn, from its props and its children's text.
11
+ * Another agent reads a reply through it too, so every prop that says something (a state, a detail, a
12
+ * badge, a unit) is in it; only props that style a node or drive local state are left out.
13
+ */
6
14
  export function uiNodeFallback(type: string, props: Readonly<Record<string, unknown>>, children: string): string {
7
15
  const label = props.label ?? props.title;
8
16
  switch (type) {
9
17
  case '$text':
10
18
  return uiPlainText(props.text);
11
- case 'Stat':
12
- return `${uiPlainText(label)}: ${uiPlainText(props.value)}${props.unit ? ` ${uiPlainText(props.unit)}` : ''}\n`;
19
+ case 'Summary':
20
+ return `${children.trimEnd()}${given(props.badge) ? ` (${uiPlainText(props.badge)})` : ''}\n`;
21
+ case 'Step':
22
+ return `${children.trimEnd()}${given(props.state) ? ` (${uiPlainText(props.state)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
23
+ case 'Progress':
24
+ return given(props.value) ? `${children.trimEnd()}: ${uiPlainText(props.value)} of ${uiPlainText(props.max ?? 100)}\n` : `${children.trimEnd()}\n`;
25
+ case 'Stat': {
26
+ const unit = given(props.unit) ? ` ${uiPlainText(props.unit)}` : '';
27
+ const previous = given(props.previous) ? ` (was ${uiPlainText(props.previous)}${unit})` : '';
28
+ return `${uiPlainText(label)}: ${uiPlainText(props.value)}${unit}${previous}\n`;
29
+ }
13
30
  case 'Source':
14
31
  return `${uiPlainText(label)}: ${uiPlainText(props.url)}\n`;
15
- case 'Table':
16
- return `${uiPlainText(props.rows ?? [])}\n`;
32
+ case 'Column':
33
+ return `${uiPlainText(props.title ?? props.key)}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
34
+ case 'Table': {
35
+ const columns = children
36
+ .split('\n')
37
+ .map((column) => column.trim())
38
+ .filter(Boolean);
39
+ return `${columns.length > 0 ? `Columns: ${columns.join(', ')}\n` : ''}${uiPlainText(props.rows ?? [])}\n`;
40
+ }
17
41
  case 'Chart':
18
- return `${uiPlainText(props.data ?? [])}\n`;
42
+ return `${uiPlainText(props.data ?? [])}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
19
43
  case 'Image':
20
44
  return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
21
45
  }
22
- const target = props.path ?? props.sha ?? (type === 'Node' ? props.id : undefined);
46
+ const path = given(props.path) && given(props.line) ? `${uiPlainText(props.path)}:${uiPlainText(props.line)}` : props.path;
47
+ const target = path ?? props.sha ?? (type === 'Node' ? props.id : undefined);
23
48
  const context = props.context ? `\n${uiPlainText(props.context)}` : '';
24
49
  const text = `${label !== undefined ? `${uiPlainText(label)}: ` : ''}${target !== undefined ? `${uiPlainText(target)} ` : ''}${children}${context}`;
25
50
  return type === 'Tag' || text.endsWith('\n') ? text : `${text}\n`;
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  UiState,
34
34
  uiInputValues,
35
35
  resolveUiChoice,
36
+ uiChoiceLabel,
36
37
  type UiChoiceSelection,
37
38
  type UiBinding,
38
39
  type UiEvaluation,
package/src/runtime.ts CHANGED
@@ -299,6 +299,25 @@ export function uiInputValues(block: UiBlock, state: UiState): Record<string, Ui
299
299
  }
300
300
 
301
301
  /* The host supplies its stored block; client data can change only declared inputs and Button targets. */
302
+ function hasError(nodes: readonly UiViewNode[]): boolean {
303
+ return nodes.some((node) => node.error !== undefined || hasError(node.children));
304
+ }
305
+
306
+ function labelText(nodes: readonly UiViewNode[]): string {
307
+ return nodes.map((node) => (node.type === '$text' ? String(node.props.text ?? '') : labelText(node.children))).join('');
308
+ }
309
+
310
+ /*
311
+ * The label an evaluated Choice sends, on one line, or null when it can never send: it failed, never
312
+ * closed, part of its label could not be read, or the label is empty. A page draws no open row for null.
313
+ */
314
+ export function uiChoiceLabel(choice: UiViewNode): string | null {
315
+ if (choice.error !== undefined || !choice.complete || hasError(choice.children)) {
316
+ return null;
317
+ }
318
+ return labelText(choice.children).replace(/\s+/g, ' ').trim() || null;
319
+ }
320
+
302
321
  export function resolveUiChoice(
303
322
  block: UiBlock,
304
323
  choiceId: string,
@@ -354,15 +373,9 @@ export function resolveUiChoice(
354
373
  if (!choice) {
355
374
  throw new UiFailure('refused_choice', 'This choice is not available in the current block.');
356
375
  }
357
- const text = (nodes: readonly UiViewNode[]): string =>
358
- nodes.map((node) => (node.type === '$text' ? String(node.props.text ?? '') : text(node.children))).join('');
359
- const hasError = (nodes: readonly UiViewNode[]): boolean => nodes.some((node) => node.error !== undefined || hasError(node.children));
360
- if (hasError(choice.children)) {
361
- throw new UiFailure('refused_choice', 'The choice label could not be fully read.');
362
- }
363
- const label = text(choice.children).replace(/\s+/g, ' ').trim();
364
- if (!label) {
365
- throw new UiFailure('refused_choice', 'A choice needs a visible label.');
376
+ const label = uiChoiceLabel(choice);
377
+ if (label === null) {
378
+ throw new UiFailure('refused_choice', hasError(choice.children) ? 'The choice label could not be fully read.' : 'A choice needs a visible label.');
366
379
  }
367
380
  return {
368
381
  label,
package/src/syntax.ts CHANGED
@@ -17,6 +17,8 @@ export interface UiSyntaxNode {
17
17
  text?: string;
18
18
  expression?: UiExpression;
19
19
  complete: boolean;
20
+ /* Set when a prop could not be read, so the node draws as its text even though its tag closed. */
21
+ invalid?: boolean;
20
22
  start: number;
21
23
  end: number;
22
24
  }
@@ -230,78 +232,138 @@ class UiParser {
230
232
  node.end = this.position;
231
233
  return;
232
234
  }
235
+ // A tag that lost a prop and has no closing tag anywhere after it was meant to close itself.
236
+ if (node.invalid && this.final && !this.closesLater(node.type)) {
237
+ node.complete = true;
238
+ node.end = this.position;
239
+ return;
240
+ }
233
241
  this.stack.push(node);
234
242
  return;
235
243
  }
236
- const prop = /^[A-Za-z_][A-Za-z_\d]*/.exec(this.source.slice(this.position));
237
- if (!prop) {
238
- if (this.position === this.source.length && !this.final) {
244
+ const propStart = this.position;
245
+ try {
246
+ if (!this.prop(node)) {
239
247
  return;
240
248
  }
241
- throw new UiFailure('invalid_prop', 'Expected a named prop.');
249
+ } catch (error) {
250
+ if (error instanceof UiFailure && error.code === 'budget_exceeded') {
251
+ throw error;
252
+ }
253
+ this.diagnose(error, propStart, node.id);
254
+ node.invalid = true;
255
+ this.skipToken();
242
256
  }
243
- const name = safeKey(prop[0]);
244
- this.position += name.length;
245
- this.whitespace();
246
- if (!this.finish('=')) {
247
- return;
257
+ }
258
+ node.end = this.position;
259
+ if (this.final) {
260
+ this.diagnose(new UiFailure('unclosed_tag', `The ${node.type} tag is incomplete.`), start, node.id);
261
+ }
262
+ }
263
+
264
+ /* Reads one prop into the node; false while a streaming prop waits for more text. */
265
+ private prop(node: UiSyntaxNode): boolean {
266
+ // At the end of the text the tag waits while it streams, and is reported incomplete once final.
267
+ if (this.position === this.source.length) {
268
+ return false;
269
+ }
270
+ const prop = /^[A-Za-z_][A-Za-z_\d]*/.exec(this.source.slice(this.position));
271
+ if (!prop) {
272
+ throw new UiFailure('invalid_prop', 'Expected a named prop.');
273
+ }
274
+ const name = safeKey(prop[0]);
275
+ this.position += name.length;
276
+ this.whitespace();
277
+ if (this.position === this.source.length && !this.final) {
278
+ return false;
279
+ }
280
+ if (this.source[this.position] !== '=') {
281
+ // A bare name is true, as in JSX.
282
+ if (Object.hasOwn(node.props, name)) {
283
+ throw new UiFailure('duplicate_prop', `Duplicate prop ${name}.`);
248
284
  }
249
- this.whitespace();
250
- let value: string;
251
- if (this.source[this.position] === '{') {
252
- const body = this.balanced();
253
- if (!body.complete) {
254
- return;
285
+ node.props[name] = parseUiExpression('true', this.budget);
286
+ return true;
287
+ }
288
+ this.position += 1;
289
+ this.whitespace();
290
+ let value: string;
291
+ if (this.source[this.position] === '{') {
292
+ const body = this.balanced();
293
+ if (!body.complete) {
294
+ return false;
295
+ }
296
+ value = body.value;
297
+ } else {
298
+ const quote = this.source[this.position];
299
+ if (quote !== '"' && quote !== "'") {
300
+ if (!this.final && this.position === this.source.length) {
301
+ return false;
255
302
  }
256
- value = body.value;
257
- } else {
258
- const quote = this.source[this.position];
259
- if (quote !== '"' && quote !== "'") {
260
- if (!this.final && this.position === this.source.length) {
261
- return;
262
- }
263
- throw new UiFailure('invalid_prop', 'Use a quoted string or a braced expression.');
303
+ throw new UiFailure('invalid_prop', 'Use a quoted string or a braced expression.');
304
+ }
305
+ const valueStart = this.position++;
306
+ let escaped = false;
307
+ let closed = false;
308
+ while (this.position < this.source.length) {
309
+ this.budget.step();
310
+ const character = this.source[this.position++];
311
+ if (escaped) {
312
+ escaped = false;
313
+ } else if (character === '\\') {
314
+ escaped = true;
315
+ } else if (character === quote) {
316
+ closed = true;
317
+ break;
264
318
  }
265
- const valueStart = this.position++;
266
- let escaped = false;
267
- let closed = false;
268
- while (this.position < this.source.length) {
269
- this.budget.step();
270
- const character = this.source[this.position++];
271
- if (escaped) {
272
- escaped = false;
273
- } else if (character === '\\') {
274
- escaped = true;
275
- } else if (character === quote) {
276
- closed = true;
277
- break;
278
- }
319
+ }
320
+ if (!closed && !this.final) {
321
+ value = this.source.slice(valueStart, this.position);
322
+ if (escaped) {
323
+ value = value.slice(0, -1);
279
324
  }
280
- if (!closed && !this.final) {
281
- value = this.source.slice(valueStart, this.position);
282
- if (escaped) {
283
- value = value.slice(0, -1);
325
+ try {
326
+ node.props[name] = parseUiExpression(`${value}${quote}`, this.budget);
327
+ } catch (error) {
328
+ if (error instanceof UiFailure && error.code === 'budget_exceeded') {
329
+ throw error;
284
330
  }
285
- try {
286
- node.props[name] = parseUiExpression(`${value}${quote}`, this.budget);
287
- } catch (error) {
288
- if (error instanceof UiFailure && error.code === 'budget_exceeded') {
289
- throw error;
290
- }
291
- }
292
- node.end = this.position;
293
- return;
294
331
  }
295
- value = this.source.slice(valueStart, this.position);
296
- }
297
- if (Object.hasOwn(node.props, name)) {
298
- throw new UiFailure('duplicate_prop', `Duplicate prop ${name}.`);
332
+ node.end = this.position;
333
+ return false;
299
334
  }
300
- node.props[name] = parseUiExpression(value, this.budget);
335
+ value = this.source.slice(valueStart, this.position);
301
336
  }
302
- node.end = this.position;
303
- if (this.final) {
304
- this.diagnose(new UiFailure('unclosed_tag', `The ${node.type} tag is incomplete.`), start, node.id);
337
+ if (Object.hasOwn(node.props, name)) {
338
+ throw new UiFailure('duplicate_prop', `Duplicate prop ${name}.`);
339
+ }
340
+ node.props[name] = parseUiExpression(value, this.budget);
341
+ return true;
342
+ }
343
+
344
+ /* Skips a prop that could not be read, up to the next space or the end of its tag, so only that prop is lost. */
345
+ private skipToken(): void {
346
+ let quote = '';
347
+ let depth = 0;
348
+ while (this.position < this.source.length) {
349
+ this.budget.step();
350
+ const character = this.source[this.position];
351
+ if (quote) {
352
+ if (character === '\\') {
353
+ this.position += 1;
354
+ } else if (character === quote) {
355
+ quote = '';
356
+ }
357
+ } else if (character === '"' || character === "'") {
358
+ quote = character;
359
+ } else if (character === '{') {
360
+ depth += 1;
361
+ } else if (character === '}') {
362
+ depth = Math.max(0, depth - 1);
363
+ } else if (depth === 0 && (/\s/.test(character) || character === '>' || this.source.startsWith('/>', this.position))) {
364
+ break;
365
+ }
366
+ this.position += 1;
305
367
  }
306
368
  }
307
369
 
@@ -336,6 +398,13 @@ class UiParser {
336
398
  return { value: this.source.slice(start), complete: false };
337
399
  }
338
400
 
401
+ /* Whether a closing tag for this type follows, written the way `tag` accepts one: whitespace may stand before its `>`. */
402
+ private closesLater(type: string): boolean {
403
+ const closing = new RegExp(`</${type}\\s*>`, 'g');
404
+ closing.lastIndex = this.position;
405
+ return closing.test(this.source);
406
+ }
407
+
339
408
  private whitespace(): void {
340
409
  while (this.position < this.source.length && /\s/.test(this.source[this.position])) {
341
410
  this.position += 1;
package/src/text.ts CHANGED
@@ -33,7 +33,7 @@ function uiExample(language: string): string {
33
33
  }
34
34
 
35
35
  export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {
36
- return `${uiSyntax(fenceLanguage)} Prose outside. Only named catalog props, no styling. $name = literal JSON; input value={$name}; Button action={@Set($name, literal)}, {@Reset($name)} or {@Reset()} changes local state only; braced expressions: @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show/Each control children. Choice labels and precise context stay visible and send once. Image: attachment or generated="latest", never URL/path. No code execution. State resets on reload. Catalog (? means optional props): ${uiCompactCatalog()}\nExample:\n${uiExample(fenceLanguage)}\n`;
36
+ return `${uiSyntax(fenceLanguage)} Prose outside. Only named catalog props, no styling. $name = literal JSON; input value={$name}; Button action={@Set($name, literal)}, {@Reset($name)} or {@Reset()} changes local state only; braced expressions: @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show/Each control children. Choices only offer optional next steps after a finished answer; ask for information or a decision needed before acting with your own question tool if you have one, otherwise in prose. Choice labels and precise context stay visible and send once. Image: attachment or generated="latest", never URL/path. No code execution. State resets on reload. Catalog (? means optional props): ${uiCompactCatalog()}\nExample:\n${uiExample(fenceLanguage)}\n`;
37
37
  }
38
38
 
39
39
  export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {