@adecore/intelligent-ui 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/catalog.d.ts CHANGED
@@ -23,7 +23,11 @@ export declare const UI_GROUPS: {
23
23
  };
24
24
  readonly content: {
25
25
  readonly description: 'Include code, attachments and sources.';
26
- readonly rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.';
26
+ readonly rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path. Gallery and Compare take chat attachments too. The host says how to attach a file of your own.';
27
+ };
28
+ readonly places: {
29
+ readonly description: 'Show places on a map the host draws.';
30
+ readonly rules: 'Give lat and lon in decimal degrees, or an address the host looks up. Never a tile or map URL. The host chooses the map.';
27
31
  };
28
32
  readonly links: {
29
33
  readonly description: 'Refer to a host-owned resource.';
@@ -109,6 +113,31 @@ export declare const UI_CATALOG: {
109
113
  }, z.core.$strict>;
110
114
  readonly parent: 'Steps';
111
115
  };
116
+ readonly Timeline: {
117
+ readonly group: 'status';
118
+ readonly description: 'Events in time order.';
119
+ readonly schema: z.ZodObject<{}, z.core.$strict>;
120
+ readonly children: readonly ['Event'];
121
+ readonly count: {
122
+ readonly min: 1;
123
+ };
124
+ };
125
+ readonly Event: {
126
+ readonly group: 'status';
127
+ readonly description: 'One event at a time or date; children are the title.';
128
+ readonly schema: z.ZodObject<{
129
+ at: z.ZodString;
130
+ tone: z.ZodOptional<z.ZodEnum<{
131
+ danger: "danger";
132
+ info: "info";
133
+ neutral: "neutral";
134
+ success: "success";
135
+ warning: "warning";
136
+ }>>;
137
+ detail: z.ZodOptional<z.ZodString>;
138
+ }, z.core.$strict>;
139
+ readonly parent: 'Timeline';
140
+ };
112
141
  readonly Stats: {
113
142
  readonly group: 'data';
114
143
  readonly description: 'A small set of key numbers.';
@@ -232,6 +261,51 @@ export declare const UI_CATALOG: {
232
261
  alt: z.ZodOptional<z.ZodString>;
233
262
  }, z.core.$strict>;
234
263
  };
264
+ readonly Gallery: {
265
+ readonly group: 'content';
266
+ readonly description: 'A grid of images with captions; at most 24.';
267
+ readonly schema: z.ZodObject<{}, z.core.$strict>;
268
+ readonly children: readonly ['Image'];
269
+ readonly count: {
270
+ readonly min: 1;
271
+ readonly max: 24;
272
+ };
273
+ };
274
+ readonly Compare: {
275
+ readonly group: 'content';
276
+ readonly description: 'Two chat attachments in one frame with a handle to wipe between them; children are the caption.';
277
+ readonly schema: z.ZodObject<{
278
+ before: z.ZodString;
279
+ after: z.ZodString;
280
+ beforeLabel: z.ZodOptional<z.ZodString>;
281
+ afterLabel: z.ZodOptional<z.ZodString>;
282
+ }, z.core.$strict>;
283
+ };
284
+ readonly Map: {
285
+ readonly group: 'places';
286
+ readonly description: 'One to 50 places on a map the host draws, with a numbered list under it; bind value to let a person pick a place.';
287
+ readonly schema: z.ZodObject<{
288
+ value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
289
+ }, z.core.$strict>;
290
+ readonly children: readonly ['Place'];
291
+ readonly binding: 'value';
292
+ readonly count: {
293
+ readonly min: 1;
294
+ readonly max: 50;
295
+ };
296
+ };
297
+ readonly Place: {
298
+ readonly group: 'places';
299
+ readonly description: 'One place by lat and lon, or by an address the host looks up; with both, the point the host finds for the address wins and lat and lon stand when it finds none. Children are the name.';
300
+ readonly schema: z.ZodObject<{
301
+ lat: z.ZodOptional<z.ZodNumber>;
302
+ lon: z.ZodOptional<z.ZodNumber>;
303
+ address: z.ZodOptional<z.ZodString>;
304
+ detail: z.ZodOptional<z.ZodString>;
305
+ value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
306
+ }, z.core.$strict>;
307
+ readonly parent: 'Map';
308
+ };
235
309
  readonly Sources: {
236
310
  readonly group: 'content';
237
311
  readonly description: 'Numbered sources without preloading.';
@@ -322,13 +396,32 @@ export declare const UI_CATALOG: {
322
396
  readonly children: readonly ['Option'];
323
397
  readonly binding: 'value';
324
398
  };
399
+ readonly Select: {
400
+ readonly group: 'inputs';
401
+ readonly description: 'Choose one local value from a dropdown, for more or longer options than Segmented holds; the text beside its Options is its label.';
402
+ readonly schema: z.ZodObject<{
403
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
404
+ }, z.core.$strict>;
405
+ readonly children: readonly ['Option'];
406
+ readonly binding: 'value';
407
+ };
325
408
  readonly Option: {
326
409
  readonly group: 'inputs';
327
410
  readonly description: 'One local alternative.';
328
411
  readonly schema: z.ZodObject<{
329
412
  value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
330
413
  }, z.core.$strict>;
331
- readonly parent: 'Segmented';
414
+ readonly parent: readonly ['Segmented', 'Select'];
415
+ };
416
+ readonly TextField: {
417
+ readonly group: 'inputs';
418
+ readonly description: 'A line of local text up to maxLength characters (256 without one, at most 1024); children are the label.';
419
+ readonly schema: z.ZodObject<{
420
+ value: z.ZodString;
421
+ maxLength: z.ZodOptional<z.ZodNumber>;
422
+ placeholder: z.ZodOptional<z.ZodString>;
423
+ }, z.core.$strict>;
424
+ readonly binding: 'value';
332
425
  };
333
426
  readonly Button: {
334
427
  readonly group: 'inputs';
@@ -370,6 +463,7 @@ export declare const UI_CATALOG: {
370
463
  readonly parent: 'Choices';
371
464
  };
372
465
  };
466
+ export type UiGroupName = keyof typeof UI_GROUPS;
373
467
  export type UiComponentName = keyof typeof UI_CATALOG;
374
468
  export type UiProps<Name extends UiComponentName> = z.infer<(typeof UI_CATALOG)[Name]['schema']>;
375
469
  export declare function isUiComponent(name: string): name is UiComponentName;
package/dist/catalog.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { parseUiEventTime } from './event-time.js';
2
3
  import { uiPropList } from './prop-list.js';
3
4
  export const UI_CATALOG_VERSION = 1;
4
5
  export const UiToneSchema = z.enum(['neutral', 'info', 'success', 'warning', 'danger']);
@@ -15,7 +16,11 @@ export const UI_GROUPS = {
15
16
  structure: { description: 'Organize distinct topics.', rules: 'Use Tabs for alternatives and Sections for independent topics.' },
16
17
  content: {
17
18
  description: 'Include code, attachments and sources.',
18
- rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.'
19
+ rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path. Gallery and Compare take chat attachments too. The host says how to attach a file of your own.'
20
+ },
21
+ places: {
22
+ description: 'Show places on a map the host draws.',
23
+ rules: 'Give lat and lon in decimal degrees, or an address the host looks up. Never a tile or map URL. The host chooses the map.'
19
24
  },
20
25
  links: { description: 'Refer to a host-owned resource.', rules: 'Targets are checked by the host. No arbitrary navigation or file reads.' },
21
26
  inputs: {
@@ -43,6 +48,17 @@ export const UI_CATALOG = {
43
48
  schema: z.strictObject({ state: z.enum(['done', 'running', 'pending', 'failed', 'skipped']), detail: text.optional() }),
44
49
  parent: 'Steps'
45
50
  },
51
+ Timeline: { group: 'status', description: 'Events in time order.', schema: empty, children: ['Event'], count: { min: 1 } },
52
+ Event: {
53
+ group: 'status',
54
+ description: 'One event at a time or date; children are the title.',
55
+ schema: z.strictObject({
56
+ at: text.refine((value) => parseUiEventTime(value) !== null, 'At must be HH:MM, YYYY-MM-DD or an ISO date-time.'),
57
+ tone,
58
+ detail: text.optional()
59
+ }),
60
+ parent: 'Timeline'
61
+ },
46
62
  Stats: { group: 'data', description: 'A small set of key numbers.', schema: empty, children: ['Stat'] },
47
63
  Stat: {
48
64
  group: 'data',
@@ -85,6 +101,34 @@ export const UI_CATALOG = {
85
101
  .strictObject({ attachment: name.optional(), generated: z.literal('latest').optional(), alt: text.optional() })
86
102
  .refine((props) => Boolean(props.attachment) !== Boolean(props.generated), 'Choose attachment or generated, exclusively.')
87
103
  },
104
+ Gallery: { group: 'content', description: 'A grid of images with captions; at most 24.', schema: empty, children: ['Image'], count: { min: 1, max: 24 } },
105
+ Compare: {
106
+ group: 'content',
107
+ description: 'Two chat attachments in one frame with a handle to wipe between them; children are the caption.',
108
+ schema: z.strictObject({ before: name, after: name, beforeLabel: text.optional(), afterLabel: text.optional() })
109
+ },
110
+ Map: {
111
+ group: 'places',
112
+ description: 'One to 50 places on a map the host draws, with a numbered list under it; bind value to let a person pick a place.',
113
+ schema: z.strictObject({ value: scalar.optional() }),
114
+ children: ['Place'],
115
+ binding: 'value',
116
+ count: { min: 1, max: 50 }
117
+ },
118
+ Place: {
119
+ group: 'places',
120
+ description: 'One place by lat and lon, or by an address the host looks up; with both, the point the host finds for the address wins and lat and lon stand when it finds none. Children are the name.',
121
+ schema: z
122
+ .strictObject({
123
+ lat: number.min(-90).max(90).optional(),
124
+ lon: number.min(-180).max(180).optional(),
125
+ address: text.max(512).optional(),
126
+ detail: text.optional(),
127
+ value: scalar.optional()
128
+ })
129
+ .refine((props) => (props.lat === undefined) === (props.lon === undefined) && (props.lat !== undefined || props.address !== undefined), 'Give lat with lon, or an address.'),
130
+ parent: 'Map'
131
+ },
88
132
  Sources: { group: 'content', description: 'Numbered sources without preloading.', schema: empty, children: ['Source'] },
89
133
  Source: {
90
134
  group: 'content',
@@ -118,7 +162,22 @@ export const UI_CATALOG = {
118
162
  binding: 'value'
119
163
  },
120
164
  Segmented: { group: 'inputs', description: 'Choose one local value.', schema: z.strictObject({ value: scalar }), children: ['Option'], binding: 'value' },
121
- Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: 'Segmented' },
165
+ Select: {
166
+ group: 'inputs',
167
+ description: 'Choose one local value from a dropdown, for more or longer options than Segmented holds; the text beside its Options is its label.',
168
+ schema: z.strictObject({ value: scalar }),
169
+ children: ['Option'],
170
+ binding: 'value'
171
+ },
172
+ Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: ['Segmented', 'Select'] },
173
+ TextField: {
174
+ group: 'inputs',
175
+ description: 'A line of local text up to maxLength characters (256 without one, at most 1024); children are the label.',
176
+ schema: z
177
+ .strictObject({ value: text, maxLength: z.number().int().min(1).max(1024).optional(), placeholder: text.max(128).optional() })
178
+ .refine((props) => props.value.length <= (props.maxLength ?? 256), 'Value must not be longer than maxLength.'),
179
+ binding: 'value'
180
+ },
122
181
  Button: {
123
182
  group: 'inputs',
124
183
  description: 'Run one local state action when pressed; children are the label.',
@@ -12,6 +12,7 @@ export declare const UI_REPLY_LIMITS: {
12
12
  export declare const UI_HOST_LIMITS: {
13
13
  readonly queries: 8;
14
14
  readonly links: 64;
15
+ readonly places: 50;
15
16
  readonly refreshMilliseconds: 10000;
16
17
  };
17
18
  export declare function uiMayReferenceHost(text: string): boolean;
package/dist/compiler.js CHANGED
@@ -2,14 +2,16 @@ import { safeKey, UI_LIMITS, UiBudget, UiFailure } from './budget.js';
2
2
  import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.js';
3
3
  import { uiActionProp, uiActionTarget, uiLocalState } from './actions.js';
4
4
  import { uiNodeFallback, uiPlainText } from './fallback.js';
5
- import { evaluateUiExpression } from './expression.js';
5
+ import { evaluateUiExpression, sameUiValue } from './expression.js';
6
6
  import { parseUiSyntax, uiDiagnostic } from './syntax.js';
7
7
  import {} from 'zod';
8
8
  // The info string a fence carries when the host names none of its own.
9
9
  export const UI_FENCE_LANGUAGE = 'ui';
10
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 };
11
+ // What a host reads for one block: its queries, its link targets, the places it looks up and how often a visible block reads again.
12
+ export const UI_HOST_LIMITS = { queries: 8, links: 64, places: 50, refreshMilliseconds: 10_000 };
13
+ /* A Place that names an address, whose tag may hold quoted text with a `>` before it. */
14
+ const ADDRESSED_PLACE = /<Place\b(?:[^>"']|"[^"]*"|'[^']*')*?\baddress\s*=/;
13
15
  /* A cheap test for whether `text` may declare a query or name a link target, before anything is read for it. */
14
16
  export function uiMayReferenceHost(text) {
15
17
  if (text.includes('@Query')) {
@@ -18,7 +20,19 @@ export function uiMayReferenceHost(text) {
18
20
  const links = Object.entries(UI_CATALOG)
19
21
  .filter(([, entry]) => entry.group === 'links')
20
22
  .map(([name]) => name);
21
- return new RegExp(`<(?:${links.join('|')})\\b`).test(text);
23
+ return new RegExp(`<(?:${links.join('|')})\\b`).test(text) || ADDRESSED_PLACE.test(text);
24
+ }
25
+ /* The children of a Map as its fallback numbers them: each Place that drew, also inside a Show or an Each, in order, and anything else as it is. */
26
+ function numberedPlaces(children) {
27
+ let number = 0;
28
+ const visit = (nodes) => nodes
29
+ .map((child) => child.type === 'Place' && !child.error
30
+ ? `${++number}. ${child.fallback}`
31
+ : child.type === 'Show' || child.type === 'Each'
32
+ ? visit(child.children)
33
+ : child.fallback)
34
+ .join('');
35
+ return visit(children);
22
36
  }
23
37
  /* A spent budget ends the whole block; every other failure only marks the node or declaration it came from. */
24
38
  function rethrowBudget(error) {
@@ -79,6 +93,10 @@ function isConstant(expression) {
79
93
  }
80
94
  }
81
95
  const CHART_SERIES = 6;
96
+ /* How many of its children's kind a container holds, the ones an Each or a Show will draw not counted. */
97
+ function countChildren(children, kinds) {
98
+ return children.reduce((count, child) => count + (kinds.includes(child.type) ? 1 : 0) + (child.type === 'Show' || child.type === 'Each' ? countChildren(child.children, kinds) : 0), 0);
99
+ }
82
100
  /* The keys besides `label` that hold a finite number in some row of a chart's data. */
83
101
  function chartSeries(data) {
84
102
  const keys = new Set();
@@ -158,8 +176,11 @@ export function compileUiBlock(source, options) {
158
176
  throw new UiFailure('unknown_component', `Unknown component ${syntax.type}.`);
159
177
  }
160
178
  const entry = UI_CATALOG[syntax.type];
161
- if ('parent' in entry && entry.parent !== parent) {
162
- throw new UiFailure('invalid_parent', `${syntax.type} belongs inside ${entry.parent}.`);
179
+ if ('parent' in entry) {
180
+ const parents = [entry.parent].flat();
181
+ if (parent === undefined || !parents.includes(parent)) {
182
+ throw new UiFailure('invalid_parent', `${syntax.type} belongs inside ${parents.join(' or ')}.`);
183
+ }
163
184
  }
164
185
  if (parent && isUiComponent(parent)) {
165
186
  const container = UI_CATALOG[parent];
@@ -224,6 +245,15 @@ export function compileUiBlock(source, options) {
224
245
  if (!unresolved && syntax.complete && syntax.type === 'Chart' && chartSeries(evaluated.data) > CHART_SERIES) {
225
246
  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
247
  }
248
+ if (syntax.complete && 'count' in entry) {
249
+ const counted = countChildren(syntax.children, entry.children);
250
+ const limit = entry.count;
251
+ if (counted < limit.min || (limit.max !== undefined && counted > limit.max)) {
252
+ throw new UiFailure('invalid_props', limit.max === undefined
253
+ ? `${syntax.type} needs at least ${limit.min} of ${entry.children.join(' or ')}.`
254
+ : `${syntax.type} takes ${limit.min} to ${limit.max} of ${entry.children.join(' or ')}.`);
255
+ }
256
+ }
227
257
  // A Choice sends its label, so one without a label can never send; whether it closed is only known once final.
228
258
  if (syntax.type === 'Choice' && syntax.complete && !syntax.children.some(isLabelSource)) {
229
259
  throw new UiFailure('invalid_props', 'A choice needs a visible label.');
@@ -243,7 +273,17 @@ export function compileUiBlock(source, options) {
243
273
  }
244
274
  const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
245
275
  node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
246
- const childText = node.children.map((child) => child.fallback).join('');
276
+ // A reader of the fallback has no control to look at, so the option a dropdown or a segmented control starts on says it is chosen.
277
+ const chosen = syntax.type === 'Select' || syntax.type === 'Segmented' ? fallbackProps.value : undefined;
278
+ const childText = syntax.type === 'Map'
279
+ ? numberedPlaces(node.children)
280
+ : node.children
281
+ .map((child) => syntax.type === 'Select' && child.type === '$text' && child.fallback.trim() !== ''
282
+ ? `${child.fallback.trim()}\n`
283
+ : chosen !== undefined && child.type === 'Option' && !child.error && sameUiValue(child.props.value, chosen)
284
+ ? `${child.fallback.trimEnd()} (chosen)\n`
285
+ : child.fallback)
286
+ .join('');
247
287
  node.fallback = uiNodeFallback(syntax.type, fallbackProps, childText);
248
288
  // A part drawn as its text keeps the words written inside it, also where its kind reads only props.
249
289
  if (node.error !== undefined && childText.trim() !== '' && !node.fallback.includes(childText.trim())) {
@@ -0,0 +1,6 @@
1
+ export interface UiEventTime {
2
+ kind: 'time' | 'date' | 'datetime';
3
+ date: Date;
4
+ written: boolean;
5
+ }
6
+ export declare function parseUiEventTime(at: string): UiEventTime | null;
@@ -0,0 +1,44 @@
1
+ const EVENT_TIME = /^(?:(\d{2}):(\d{2})|(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?)$/;
2
+ function validClock(hour, minute, second = 0) {
3
+ return hour <= 23 && minute <= 59 && second <= 59;
4
+ }
5
+ /* The minutes east of UTC that `Z` or `+02:00` stand for, or null for an offset no zone has: from -12:00 to +14:00. */
6
+ function zoneOffset(zone) {
7
+ if (zone === 'Z') {
8
+ return 0;
9
+ }
10
+ const [hours, minutes] = [Number(zone.slice(1, 3)), Number(zone.slice(4, 6))];
11
+ const offset = (zone[0] === '-' ? -1 : 1) * (hours * 60 + minutes);
12
+ return minutes > 59 || offset < -12 * 60 || offset > 14 * 60 ? null : offset;
13
+ }
14
+ /* The moment an Event's `at` names, or null when it is no `HH:MM`, `YYYY-MM-DD` or ISO date-time with an optional offset. */
15
+ export function parseUiEventTime(at) {
16
+ const match = EVENT_TIME.exec(at);
17
+ if (match === null) {
18
+ return null;
19
+ }
20
+ const [, clockHour, clockMinute, year, month, day, hour, minute, second, zone] = match.map((part) => part);
21
+ if (clockHour !== undefined) {
22
+ // The day is not shown for a time, so any day will do.
23
+ return validClock(Number(clockHour), Number(clockMinute))
24
+ ? { kind: 'time', date: new Date(Date.UTC(2000, 0, 1, Number(clockHour), Number(clockMinute))), written: true }
25
+ : null;
26
+ }
27
+ const [y, m, d] = [Number(year), Number(month), Number(day)];
28
+ const calendar = new Date(Date.UTC(y, m - 1, d));
29
+ if (calendar.getUTCFullYear() !== y || calendar.getUTCMonth() !== m - 1 || calendar.getUTCDate() !== d) {
30
+ return null;
31
+ }
32
+ if (hour === undefined) {
33
+ return { kind: 'date', date: calendar, written: true };
34
+ }
35
+ const [h, min, sec] = [Number(hour), Number(minute), Number(second ?? 0)];
36
+ if (!validClock(h, min, sec)) {
37
+ return null;
38
+ }
39
+ if (zone === undefined) {
40
+ return { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec)), written: true };
41
+ }
42
+ const offset = zoneOffset(zone);
43
+ return offset === null ? null : { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec) - offset * 60000), written: false };
44
+ }
package/dist/fallback.js CHANGED
@@ -18,6 +18,8 @@ export function uiNodeFallback(type, props, children) {
18
18
  return `${children.trimEnd()}${given(props.badge) ? ` (${uiPlainText(props.badge)})` : ''}\n`;
19
19
  case 'Step':
20
20
  return `${children.trimEnd()}${given(props.state) ? ` (${uiPlainText(props.state)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
21
+ case 'Event':
22
+ return `${uiPlainText(props.at)} ${children.trimEnd()}${given(props.tone) ? ` (${uiPlainText(props.tone)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
21
23
  case 'Progress':
22
24
  return given(props.value) ? `${children.trimEnd()}: ${uiPlainText(props.value)} of ${uiPlainText(props.max ?? 100)}\n` : `${children.trimEnd()}\n`;
23
25
  case 'Stat': {
@@ -38,6 +40,18 @@ export function uiNodeFallback(type, props, children) {
38
40
  }
39
41
  case 'Chart':
40
42
  return `${uiPlainText(props.data ?? [])}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
43
+ case 'Compare':
44
+ return `[Compare: ${uiPlainText(props.beforeLabel || 'Before')} ${uiPlainText(props.before)}, ${uiPlainText(props.afterLabel || 'After')} ${uiPlainText(props.after)}]${children.trim() ? ` ${children.trim()}` : ''}\n`;
45
+ case 'Place': {
46
+ const point = given(props.lat) && given(props.lon) ? ` (${uiPlainText(props.lat)}, ${uiPlainText(props.lon)})` : '';
47
+ const address = given(props.address) ? `, ${uiPlainText(props.address)}` : '';
48
+ const detail = given(props.detail) ? `: ${uiPlainText(props.detail)}` : '';
49
+ // One line per place, so the numbering of its map counts places and not the lines of a name.
50
+ return `${children.trim()}${point}${address}${detail}`.replace(/\s+/g, ' ').trim() + '\n';
51
+ }
52
+ case 'Map':
53
+ // The places are numbered where the compiler joins them, since only it knows which of the children drew.
54
+ return children;
41
55
  case 'Image':
42
56
  return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
43
57
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText, type UiComponentName, type UiProps, type UiTone } from './catalog.ts';
1
+ export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText, type UiComponentName, type UiGroupName, type UiProps, type UiTone } from './catalog.ts';
2
+ export { parseUiEventTime, type UiEventTime } from './event-time.ts';
2
3
  export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
3
4
  export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
4
5
  export { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntax, type UiSyntaxNode } from './syntax.ts';
@@ -6,6 +7,7 @@ export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compile
6
7
  export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, uiChoiceLabel, type UiChoiceSelection, type UiBinding, type UiEvaluation, type UiViewNode } from './runtime.ts';
7
8
  export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
8
9
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
9
- export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
10
+ export { uiCompactCatalog, uiSessionNote, uiGroupReference, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
10
11
  export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
12
+ export { uiPlaceQueries, type UiPlaceQuery } from './places.ts';
11
13
  export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText } from './catalog.js';
2
+ export { parseUiEventTime } from './event-time.js';
2
3
  export { UI_LIMITS, UiBudget, UiFailure, safeKey } from './budget.js';
3
4
  export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue } from './expression.js';
4
5
  export { parseUiSyntax, uiDiagnostic } from './syntax.js';
@@ -6,6 +7,7 @@ export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compile
6
7
  export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, uiChoiceLabel } from './runtime.js';
7
8
  export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.js';
8
9
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.js';
9
- export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.js';
10
+ export { uiCompactCatalog, uiSessionNote, uiGroupReference, uiReferenceText, uiFallbackText } from './text.js';
10
11
  export { UI_STREAM_INTERVAL_MS, UiStream } from './stream.js';
12
+ export { uiPlaceQueries } from './places.js';
11
13
  export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets } from './links.js';
package/dist/inputs.js CHANGED
@@ -9,9 +9,12 @@ export function uiCompiledNodes(nodes, into = new Map()) {
9
9
  }
10
10
  /* Whether a control's value is one its own visible options offer. */
11
11
  function offered(node) {
12
- if (node.type === 'Segmented') {
12
+ if (node.type === 'Segmented' || node.type === 'Select') {
13
13
  return node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
14
14
  }
15
+ if (node.type === 'Map') {
16
+ return node.children.some((place) => place.type === 'Place' && !place.error && place.props.value === node.props.value);
17
+ }
15
18
  if (node.type === 'Checklist') {
16
19
  const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
17
20
  return node.props.value.every((value) => options.includes(value));
@@ -0,0 +1,7 @@
1
+ import type { UiLimits } from './budget.ts';
2
+ import type { UiBlock } from './compiler.ts';
3
+ export interface UiPlaceQuery {
4
+ address: string;
5
+ name: string;
6
+ }
7
+ export declare function uiPlaceQueries(block: UiBlock, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>, limits?: Partial<UiLimits>): Record<string, UiPlaceQuery>;
package/dist/places.js ADDED
@@ -0,0 +1,32 @@
1
+ import { uiValidatedState } from './query.js';
2
+ import { evaluateUiBlock } from './runtime.js';
3
+ function nameOf(node) {
4
+ return node.children
5
+ .map((child) => (child.type === '$text' ? String(child.props.text ?? '') : nameOf(child)))
6
+ .join('')
7
+ .replace(/\s+/g, ' ')
8
+ .trim();
9
+ }
10
+ /*
11
+ * The complete, valid Places that give an address, by node id, as the block evaluates with `input` and the
12
+ * values of its `queries`. A place that also gives coordinates is looked up too: what the host finds for
13
+ * the address wins, and the coordinates stand when it finds nothing.
14
+ */
15
+ export function uiPlaceQueries(block, input = {}, queries = {}, limits = {}) {
16
+ const state = uiValidatedState(block, input, queries, limits);
17
+ const places = Object.create(null);
18
+ const visit = (nodes) => {
19
+ for (const node of nodes) {
20
+ if (node.error || !node.complete) {
21
+ continue;
22
+ }
23
+ const { address } = node.props;
24
+ if (node.type === 'Place' && typeof address === 'string' && address.trim() !== '') {
25
+ places[node.id] = { address: address.trim(), name: nameOf(node) };
26
+ }
27
+ visit(node.children);
28
+ }
29
+ };
30
+ visit(evaluateUiBlock(block, state, limits).nodes);
31
+ return places;
32
+ }
package/dist/runtime.d.ts CHANGED
@@ -16,6 +16,7 @@ export interface UiViewNode<Props = Record<string, unknown>> {
16
16
  complete: boolean;
17
17
  fallback: string;
18
18
  error?: string;
19
+ omitted?: number;
19
20
  onAction?(): void;
20
21
  }
21
22
  export interface UiEvaluation {
package/dist/runtime.js CHANGED
@@ -207,6 +207,23 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
207
207
  };
208
208
  }
209
209
  view.children = children(node.children, variables, depth + 1, suffix);
210
+ if ('count' in entry) {
211
+ const limit = entry.count;
212
+ const kinds = entry.children;
213
+ const counted = view.children.filter((child) => kinds.includes(child.type)).length;
214
+ // What an Each or a Show drew is only known now, so the count the compiler checked on the source is checked again.
215
+ if (node.complete && counted < limit.min) {
216
+ throw new UiFailure('invalid_props', `${node.type} needs at least ${limit.min} of ${kinds.join(' or ')}.`);
217
+ }
218
+ if (limit.max !== undefined && counted > limit.max) {
219
+ let kept = 0;
220
+ view.children = view.children.filter((child) => !kinds.includes(child.type) || ++kept <= limit.max);
221
+ view.omitted = counted - limit.max;
222
+ if (result.diagnostics.length < budget.limits.diagnostics) {
223
+ result.diagnostics.push(uiDiagnostic(new UiFailure('too_many_children', `${node.type} shows its first ${limit.max} of ${kinds.join(' or ')}.`), node.start, node.end, node.id));
224
+ }
225
+ }
226
+ }
210
227
  return [view];
211
228
  }
212
229
  catch (error) {
package/dist/text.d.ts CHANGED
@@ -1,8 +1,12 @@
1
+ import { type UiGroupName } from './catalog.ts';
1
2
  import { type UiBlock } from './compiler.ts';
2
3
  export declare function uiCompactCatalog(): string;
3
4
  export interface UiTextOptions {
4
5
  fenceLanguage?: string;
6
+ catalog?: 'props' | 'names';
7
+ detailHint?: string;
5
8
  }
6
- export declare function uiSessionNote({ fenceLanguage }?: UiTextOptions): string;
9
+ export declare function uiSessionNote({ fenceLanguage, catalog, detailHint }?: UiTextOptions): string;
10
+ export declare function uiGroupReference(group: UiGroupName): string;
7
11
  export declare function uiReferenceText({ fenceLanguage }?: UiTextOptions): string;
8
12
  export declare function uiFallbackText(text: string, blocks: readonly UiBlock[]): string;
package/dist/text.js CHANGED
@@ -22,21 +22,30 @@ function uiExample(language) {
22
22
  '```'
23
23
  ].join('\n');
24
24
  }
25
- export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE } = {}) {
26
- 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 are shortcuts after finished work that the person may ignore. When your turn would end waiting on the person (a missing fact, a decision, a go-ahead for work you would otherwise do), ask with your own question tool if you have one, otherwise in prose, never with Choices. 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`;
25
+ function uiGroupComponents(group) {
26
+ return Object.entries(UI_CATALOG).filter(([, entry]) => entry.group === group);
27
+ }
28
+ /* One line per group with the names of its components, for a host that hands the props out per group. */
29
+ function uiGroupNames(detailHint) {
30
+ const lines = Object.keys(UI_GROUPS).map((group) => `${group}: ${uiGroupComponents(group)
31
+ .map(([name]) => name)
32
+ .join(', ')}`);
33
+ return `Components by group${detailHint ? ` (${detailHint})` : ''}:\n${lines.join('\n')}`;
34
+ }
35
+ export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE, catalog = 'props', detailHint } = {}) {
36
+ const components = catalog === 'names' ? uiGroupNames(detailHint) : `Catalog (? means optional props): ${uiCompactCatalog()}`;
37
+ 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 are shortcuts after finished work that the person may ignore. When your turn would end waiting on the person (a missing fact, a decision, a go-ahead for work you would otherwise do), ask with your own question tool if you have one, otherwise in prose, never with Choices. 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. ${components}\nExample:\n${uiExample(fenceLanguage)}\n`;
38
+ }
39
+ /* One group as the reference writes it: its description and rules, then every component with its props. */
40
+ export function uiGroupReference(group) {
41
+ const components = uiGroupComponents(group).map(([name, entry]) => {
42
+ const props = uiPropList(entry).join(', ');
43
+ return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
44
+ });
45
+ return [UI_GROUPS[group].description, UI_GROUPS[group].rules, ...components].join('\n');
27
46
  }
28
47
  export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE } = {}) {
29
- const groups = Object.entries(UI_GROUPS)
30
- .map(([group, definition]) => {
31
- const components = Object.entries(UI_CATALOG)
32
- .filter(([, entry]) => entry.group === group)
33
- .map(([name, entry]) => {
34
- const props = uiPropList(entry).join(', ');
35
- return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
36
- });
37
- return [definition.description, definition.rules, ...components].join('\n');
38
- })
39
- .join('\n\n');
48
+ const groups = Object.keys(UI_GROUPS).map(uiGroupReference).join('\n\n');
40
49
  return `${uiSyntax(fenceLanguage)}\nExample:\n${uiExample(fenceLanguage)}\n\n${groups}`;
41
50
  }
42
51
  export function uiFallbackText(text, blocks) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/intelligent-ui",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
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",
@@ -32,6 +32,11 @@
32
32
  "types": "./dist/links.d.ts",
33
33
  "default": "./dist/links.js"
34
34
  },
35
+ "./places": {
36
+ "source": "./src/places.ts",
37
+ "types": "./dist/places.d.ts",
38
+ "default": "./dist/places.js"
39
+ },
35
40
  "./query": {
36
41
  "source": "./src/query.ts",
37
42
  "types": "./dist/query.d.ts",
@@ -42,6 +47,11 @@
42
47
  "types": "./dist/catalog.d.ts",
43
48
  "default": "./dist/catalog.js"
44
49
  },
50
+ "./event-time": {
51
+ "source": "./src/event-time.ts",
52
+ "types": "./dist/event-time.d.ts",
53
+ "default": "./dist/event-time.js"
54
+ },
45
55
  "./budget": {
46
56
  "source": "./src/budget.ts",
47
57
  "types": "./dist/budget.d.ts",
package/src/catalog.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { parseUiEventTime } from './event-time.ts';
2
3
  import { uiPropList } from './prop-list.ts';
3
4
 
4
5
  export const UI_CATALOG_VERSION = 1;
@@ -19,7 +20,11 @@ export const UI_GROUPS = {
19
20
  structure: { description: 'Organize distinct topics.', rules: 'Use Tabs for alternatives and Sections for independent topics.' },
20
21
  content: {
21
22
  description: 'Include code, attachments and sources.',
22
- rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.'
23
+ rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path. Gallery and Compare take chat attachments too. The host says how to attach a file of your own.'
24
+ },
25
+ places: {
26
+ description: 'Show places on a map the host draws.',
27
+ rules: 'Give lat and lon in decimal degrees, or an address the host looks up. Never a tile or map URL. The host chooses the map.'
23
28
  },
24
29
  links: { description: 'Refer to a host-owned resource.', rules: 'Targets are checked by the host. No arbitrary navigation or file reads.' },
25
30
  inputs: {
@@ -48,6 +53,17 @@ export const UI_CATALOG = {
48
53
  schema: z.strictObject({ state: z.enum(['done', 'running', 'pending', 'failed', 'skipped']), detail: text.optional() }),
49
54
  parent: 'Steps'
50
55
  },
56
+ Timeline: { group: 'status', description: 'Events in time order.', schema: empty, children: ['Event'], count: { min: 1 } },
57
+ Event: {
58
+ group: 'status',
59
+ description: 'One event at a time or date; children are the title.',
60
+ schema: z.strictObject({
61
+ at: text.refine((value) => parseUiEventTime(value) !== null, 'At must be HH:MM, YYYY-MM-DD or an ISO date-time.'),
62
+ tone,
63
+ detail: text.optional()
64
+ }),
65
+ parent: 'Timeline'
66
+ },
51
67
  Stats: { group: 'data', description: 'A small set of key numbers.', schema: empty, children: ['Stat'] },
52
68
  Stat: {
53
69
  group: 'data',
@@ -90,6 +106,38 @@ export const UI_CATALOG = {
90
106
  .strictObject({ attachment: name.optional(), generated: z.literal('latest').optional(), alt: text.optional() })
91
107
  .refine((props) => Boolean(props.attachment) !== Boolean(props.generated), 'Choose attachment or generated, exclusively.')
92
108
  },
109
+ Gallery: { group: 'content', description: 'A grid of images with captions; at most 24.', schema: empty, children: ['Image'], count: { min: 1, max: 24 } },
110
+ Compare: {
111
+ group: 'content',
112
+ description: 'Two chat attachments in one frame with a handle to wipe between them; children are the caption.',
113
+ schema: z.strictObject({ before: name, after: name, beforeLabel: text.optional(), afterLabel: text.optional() })
114
+ },
115
+ Map: {
116
+ group: 'places',
117
+ description: 'One to 50 places on a map the host draws, with a numbered list under it; bind value to let a person pick a place.',
118
+ schema: z.strictObject({ value: scalar.optional() }),
119
+ children: ['Place'],
120
+ binding: 'value',
121
+ count: { min: 1, max: 50 }
122
+ },
123
+ Place: {
124
+ group: 'places',
125
+ description:
126
+ 'One place by lat and lon, or by an address the host looks up; with both, the point the host finds for the address wins and lat and lon stand when it finds none. Children are the name.',
127
+ schema: z
128
+ .strictObject({
129
+ lat: number.min(-90).max(90).optional(),
130
+ lon: number.min(-180).max(180).optional(),
131
+ address: text.max(512).optional(),
132
+ detail: text.optional(),
133
+ value: scalar.optional()
134
+ })
135
+ .refine(
136
+ (props) => (props.lat === undefined) === (props.lon === undefined) && (props.lat !== undefined || props.address !== undefined),
137
+ 'Give lat with lon, or an address.'
138
+ ),
139
+ parent: 'Map'
140
+ },
93
141
  Sources: { group: 'content', description: 'Numbered sources without preloading.', schema: empty, children: ['Source'] },
94
142
  Source: {
95
143
  group: 'content',
@@ -123,7 +171,22 @@ export const UI_CATALOG = {
123
171
  binding: 'value'
124
172
  },
125
173
  Segmented: { group: 'inputs', description: 'Choose one local value.', schema: z.strictObject({ value: scalar }), children: ['Option'], binding: 'value' },
126
- Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: 'Segmented' },
174
+ Select: {
175
+ group: 'inputs',
176
+ description: 'Choose one local value from a dropdown, for more or longer options than Segmented holds; the text beside its Options is its label.',
177
+ schema: z.strictObject({ value: scalar }),
178
+ children: ['Option'],
179
+ binding: 'value'
180
+ },
181
+ Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: ['Segmented', 'Select'] },
182
+ TextField: {
183
+ group: 'inputs',
184
+ description: 'A line of local text up to maxLength characters (256 without one, at most 1024); children are the label.',
185
+ schema: z
186
+ .strictObject({ value: text, maxLength: z.number().int().min(1).max(1024).optional(), placeholder: text.max(128).optional() })
187
+ .refine((props) => props.value.length <= (props.maxLength ?? 256), 'Value must not be longer than maxLength.'),
188
+ binding: 'value'
189
+ },
127
190
  Button: {
128
191
  group: 'inputs',
129
192
  description: 'Run one local state action when pressed; children are the label.',
@@ -145,6 +208,7 @@ export const UI_CATALOG = {
145
208
  }
146
209
  } as const;
147
210
 
211
+ export type UiGroupName = keyof typeof UI_GROUPS;
148
212
  export type UiComponentName = keyof typeof UI_CATALOG;
149
213
  export type UiProps<Name extends UiComponentName> = z.infer<(typeof UI_CATALOG)[Name]['schema']>;
150
214
 
package/src/compiler.ts CHANGED
@@ -2,7 +2,7 @@ import { safeKey, UI_LIMITS, UiBudget, UiFailure, type UiLimits } from './budget
2
2
  import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.ts';
3
3
  import { uiActionProp, uiActionTarget, uiLocalState } from './actions.ts';
4
4
  import { uiNodeFallback, uiPlainText } from './fallback.ts';
5
- import { evaluateUiExpression, type UiExpression, type UiValue } from './expression.ts';
5
+ import { evaluateUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
6
6
  import { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntaxNode } from './syntax.ts';
7
7
  import { type z } from 'zod';
8
8
 
@@ -11,8 +11,11 @@ export const UI_FENCE_LANGUAGE = 'ui';
11
11
 
12
12
  export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 } as const;
13
13
 
14
- // What a host reads for one block: its queries, its link targets and how often a visible block reads again.
15
- export const UI_HOST_LIMITS = { queries: 8, links: 64, refreshMilliseconds: 10_000 } as const;
14
+ // What a host reads for one block: its queries, its link targets, the places it looks up and how often a visible block reads again.
15
+ export const UI_HOST_LIMITS = { queries: 8, links: 64, places: 50, refreshMilliseconds: 10_000 } as const;
16
+
17
+ /* A Place that names an address, whose tag may hold quoted text with a `>` before it. */
18
+ const ADDRESSED_PLACE = /<Place\b(?:[^>"']|"[^"]*"|'[^']*')*?\baddress\s*=/;
16
19
 
17
20
  /* A cheap test for whether `text` may declare a query or name a link target, before anything is read for it. */
18
21
  export function uiMayReferenceHost(text: string): boolean {
@@ -22,7 +25,23 @@ export function uiMayReferenceHost(text: string): boolean {
22
25
  const links = Object.entries(UI_CATALOG)
23
26
  .filter(([, entry]) => entry.group === 'links')
24
27
  .map(([name]) => name);
25
- return new RegExp(`<(?:${links.join('|')})\\b`).test(text);
28
+ return new RegExp(`<(?:${links.join('|')})\\b`).test(text) || ADDRESSED_PLACE.test(text);
29
+ }
30
+
31
+ /* The children of a Map as its fallback numbers them: each Place that drew, also inside a Show or an Each, in order, and anything else as it is. */
32
+ function numberedPlaces(children: readonly UiNode[]): string {
33
+ let number = 0;
34
+ const visit = (nodes: readonly UiNode[]): string =>
35
+ nodes
36
+ .map((child) =>
37
+ child.type === 'Place' && !child.error
38
+ ? `${++number}. ${child.fallback}`
39
+ : child.type === 'Show' || child.type === 'Each'
40
+ ? visit(child.children)
41
+ : child.fallback
42
+ )
43
+ .join('');
44
+ return visit(children);
26
45
  }
27
46
 
28
47
  export interface UiNode {
@@ -140,6 +159,15 @@ function isConstant(expression: UiExpression): boolean {
140
159
 
141
160
  const CHART_SERIES = 6;
142
161
 
162
+ /* How many of its children's kind a container holds, the ones an Each or a Show will draw not counted. */
163
+ function countChildren(children: readonly UiSyntaxNode[], kinds: readonly string[]): number {
164
+ return children.reduce(
165
+ (count, child) =>
166
+ count + (kinds.includes(child.type) ? 1 : 0) + (child.type === 'Show' || child.type === 'Each' ? countChildren(child.children, kinds) : 0),
167
+ 0
168
+ );
169
+ }
170
+
143
171
  /* The keys besides `label` that hold a finite number in some row of a chart's data. */
144
172
  function chartSeries(data: unknown): number {
145
173
  const keys = new Set<string>();
@@ -219,8 +247,11 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
219
247
  throw new UiFailure('unknown_component', `Unknown component ${syntax.type}.`);
220
248
  }
221
249
  const entry = UI_CATALOG[syntax.type];
222
- if ('parent' in entry && entry.parent !== parent) {
223
- throw new UiFailure('invalid_parent', `${syntax.type} belongs inside ${entry.parent}.`);
250
+ if ('parent' in entry) {
251
+ const parents: readonly string[] = [entry.parent].flat();
252
+ if (parent === undefined || !parents.includes(parent)) {
253
+ throw new UiFailure('invalid_parent', `${syntax.type} belongs inside ${parents.join(' or ')}.`);
254
+ }
224
255
  }
225
256
  if (parent && isUiComponent(parent)) {
226
257
  const container = UI_CATALOG[parent];
@@ -290,6 +321,18 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
290
321
  syntax.id
291
322
  );
292
323
  }
324
+ if (syntax.complete && 'count' in entry) {
325
+ const counted = countChildren(syntax.children, entry.children);
326
+ const limit = entry.count as { min: number; max?: number };
327
+ if (counted < limit.min || (limit.max !== undefined && counted > limit.max)) {
328
+ throw new UiFailure(
329
+ 'invalid_props',
330
+ limit.max === undefined
331
+ ? `${syntax.type} needs at least ${limit.min} of ${entry.children.join(' or ')}.`
332
+ : `${syntax.type} takes ${limit.min} to ${limit.max} of ${entry.children.join(' or ')}.`
333
+ );
334
+ }
335
+ }
293
336
  // A Choice sends its label, so one without a label can never send; whether it closed is only known once final.
294
337
  if (syntax.type === 'Choice' && syntax.complete && !syntax.children.some(isLabelSource)) {
295
338
  throw new UiFailure('invalid_props', 'A choice needs a visible label.');
@@ -308,7 +351,20 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
308
351
  }
309
352
  const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
310
353
  node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
311
- const childText = node.children.map((child) => child.fallback).join('');
354
+ // A reader of the fallback has no control to look at, so the option a dropdown or a segmented control starts on says it is chosen.
355
+ const chosen = syntax.type === 'Select' || syntax.type === 'Segmented' ? fallbackProps.value : undefined;
356
+ const childText =
357
+ syntax.type === 'Map'
358
+ ? numberedPlaces(node.children)
359
+ : node.children
360
+ .map((child) =>
361
+ syntax.type === 'Select' && child.type === '$text' && child.fallback.trim() !== ''
362
+ ? `${child.fallback.trim()}\n`
363
+ : chosen !== undefined && child.type === 'Option' && !child.error && sameUiValue(child.props.value, chosen)
364
+ ? `${child.fallback.trimEnd()} (chosen)\n`
365
+ : child.fallback
366
+ )
367
+ .join('');
312
368
  node.fallback = uiNodeFallback(syntax.type, fallbackProps, childText);
313
369
  // A part drawn as its text keeps the words written inside it, also where its kind reads only props.
314
370
  if (node.error !== undefined && childText.trim() !== '' && !node.fallback.includes(childText.trim())) {
@@ -0,0 +1,54 @@
1
+ const EVENT_TIME = /^(?:(\d{2}):(\d{2})|(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?)$/;
2
+
3
+ export interface UiEventTime {
4
+ kind: 'time' | 'date' | 'datetime';
5
+ date: Date;
6
+ // True when the text gave no offset: `date` then holds the parts as written in UTC, to be written in UTC, so no zone or clock change moves them. False is a real instant, to be written in the reader's zone.
7
+ written: boolean;
8
+ }
9
+
10
+ function validClock(hour: number, minute: number, second = 0): boolean {
11
+ return hour <= 23 && minute <= 59 && second <= 59;
12
+ }
13
+
14
+ /* The minutes east of UTC that `Z` or `+02:00` stand for, or null for an offset no zone has: from -12:00 to +14:00. */
15
+ function zoneOffset(zone: string): number | null {
16
+ if (zone === 'Z') {
17
+ return 0;
18
+ }
19
+ const [hours, minutes] = [Number(zone.slice(1, 3)), Number(zone.slice(4, 6))];
20
+ const offset = (zone[0] === '-' ? -1 : 1) * (hours * 60 + minutes);
21
+ return minutes > 59 || offset < -12 * 60 || offset > 14 * 60 ? null : offset;
22
+ }
23
+
24
+ /* The moment an Event's `at` names, or null when it is no `HH:MM`, `YYYY-MM-DD` or ISO date-time with an optional offset. */
25
+ export function parseUiEventTime(at: string): UiEventTime | null {
26
+ const match = EVENT_TIME.exec(at);
27
+ if (match === null) {
28
+ return null;
29
+ }
30
+ const [, clockHour, clockMinute, year, month, day, hour, minute, second, zone] = match.map((part) => part as string | undefined);
31
+ if (clockHour !== undefined) {
32
+ // The day is not shown for a time, so any day will do.
33
+ return validClock(Number(clockHour), Number(clockMinute))
34
+ ? { kind: 'time', date: new Date(Date.UTC(2000, 0, 1, Number(clockHour), Number(clockMinute))), written: true }
35
+ : null;
36
+ }
37
+ const [y, m, d] = [Number(year), Number(month), Number(day)];
38
+ const calendar = new Date(Date.UTC(y, m - 1, d));
39
+ if (calendar.getUTCFullYear() !== y || calendar.getUTCMonth() !== m - 1 || calendar.getUTCDate() !== d) {
40
+ return null;
41
+ }
42
+ if (hour === undefined) {
43
+ return { kind: 'date', date: calendar, written: true };
44
+ }
45
+ const [h, min, sec] = [Number(hour), Number(minute), Number(second ?? 0)];
46
+ if (!validClock(h, min, sec)) {
47
+ return null;
48
+ }
49
+ if (zone === undefined) {
50
+ return { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec)), written: true };
51
+ }
52
+ const offset = zoneOffset(zone);
53
+ return offset === null ? null : { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec) - offset * 60000), written: false };
54
+ }
package/src/fallback.ts CHANGED
@@ -20,6 +20,8 @@ export function uiNodeFallback(type: string, props: Readonly<Record<string, unkn
20
20
  return `${children.trimEnd()}${given(props.badge) ? ` (${uiPlainText(props.badge)})` : ''}\n`;
21
21
  case 'Step':
22
22
  return `${children.trimEnd()}${given(props.state) ? ` (${uiPlainText(props.state)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
23
+ case 'Event':
24
+ return `${uiPlainText(props.at)} ${children.trimEnd()}${given(props.tone) ? ` (${uiPlainText(props.tone)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
23
25
  case 'Progress':
24
26
  return given(props.value) ? `${children.trimEnd()}: ${uiPlainText(props.value)} of ${uiPlainText(props.max ?? 100)}\n` : `${children.trimEnd()}\n`;
25
27
  case 'Stat': {
@@ -40,6 +42,18 @@ export function uiNodeFallback(type: string, props: Readonly<Record<string, unkn
40
42
  }
41
43
  case 'Chart':
42
44
  return `${uiPlainText(props.data ?? [])}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
45
+ case 'Compare':
46
+ return `[Compare: ${uiPlainText(props.beforeLabel || 'Before')} ${uiPlainText(props.before)}, ${uiPlainText(props.afterLabel || 'After')} ${uiPlainText(props.after)}]${children.trim() ? ` ${children.trim()}` : ''}\n`;
47
+ case 'Place': {
48
+ const point = given(props.lat) && given(props.lon) ? ` (${uiPlainText(props.lat)}, ${uiPlainText(props.lon)})` : '';
49
+ const address = given(props.address) ? `, ${uiPlainText(props.address)}` : '';
50
+ const detail = given(props.detail) ? `: ${uiPlainText(props.detail)}` : '';
51
+ // One line per place, so the numbering of its map counts places and not the lines of a name.
52
+ return `${children.trim()}${point}${address}${detail}`.replace(/\s+/g, ' ').trim() + '\n';
53
+ }
54
+ case 'Map':
55
+ // The places are numbered where the compiler joins them, since only it knows which of the children drew.
56
+ return children;
43
57
  case 'Image':
44
58
  return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
45
59
  }
package/src/index.ts CHANGED
@@ -6,9 +6,11 @@ export {
6
6
  isUiComponent,
7
7
  uiCatalogText,
8
8
  type UiComponentName,
9
+ type UiGroupName,
9
10
  type UiProps,
10
11
  type UiTone
11
12
  } from './catalog.ts';
13
+ export { parseUiEventTime, type UiEventTime } from './event-time.ts';
12
14
  export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
13
15
  export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
14
16
  export { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntax, type UiSyntaxNode } from './syntax.ts';
@@ -41,7 +43,8 @@ export {
41
43
  } from './runtime.ts';
42
44
  export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
43
45
  export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
44
- export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
46
+ export { uiCompactCatalog, uiSessionNote, uiGroupReference, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
45
47
  export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
46
48
 
49
+ export { uiPlaceQueries, type UiPlaceQuery } from './places.ts';
47
50
  export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
package/src/inputs.ts CHANGED
@@ -13,9 +13,12 @@ export function uiCompiledNodes(nodes: readonly UiNode[], into = new Map<string,
13
13
 
14
14
  /* Whether a control's value is one its own visible options offer. */
15
15
  function offered(node: UiViewNode): boolean {
16
- if (node.type === 'Segmented') {
16
+ if (node.type === 'Segmented' || node.type === 'Select') {
17
17
  return node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
18
18
  }
19
+ if (node.type === 'Map') {
20
+ return node.children.some((place) => place.type === 'Place' && !place.error && place.props.value === node.props.value);
21
+ }
19
22
  if (node.type === 'Checklist') {
20
23
  const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
21
24
  return (node.props.value as unknown[]).every((value) => options.includes(value));
package/src/places.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { UiLimits } from './budget.ts';
2
+ import type { UiBlock } from './compiler.ts';
3
+ import { uiValidatedState } from './query.ts';
4
+ import { evaluateUiBlock, type UiViewNode } from './runtime.ts';
5
+
6
+ /* A place the agent named by an address, with the name it gave it, for a host to look up. */
7
+ export interface UiPlaceQuery {
8
+ address: string;
9
+ name: string;
10
+ }
11
+
12
+ function nameOf(node: UiViewNode): string {
13
+ return node.children
14
+ .map((child) => (child.type === '$text' ? String(child.props.text ?? '') : nameOf(child)))
15
+ .join('')
16
+ .replace(/\s+/g, ' ')
17
+ .trim();
18
+ }
19
+
20
+ /*
21
+ * The complete, valid Places that give an address, by node id, as the block evaluates with `input` and the
22
+ * values of its `queries`. A place that also gives coordinates is looked up too: what the host finds for
23
+ * the address wins, and the coordinates stand when it finds nothing.
24
+ */
25
+ export function uiPlaceQueries(
26
+ block: UiBlock,
27
+ input: Readonly<Record<string, unknown>> = {},
28
+ queries: Readonly<Record<string, unknown>> = {},
29
+ limits: Partial<UiLimits> = {}
30
+ ): Record<string, UiPlaceQuery> {
31
+ const state = uiValidatedState(block, input, queries, limits);
32
+ const places: Record<string, UiPlaceQuery> = Object.create(null);
33
+ const visit = (nodes: readonly UiViewNode[]) => {
34
+ for (const node of nodes) {
35
+ if (node.error || !node.complete) {
36
+ continue;
37
+ }
38
+ const { address } = node.props as { address?: string };
39
+ if (node.type === 'Place' && typeof address === 'string' && address.trim() !== '') {
40
+ places[node.id] = { address: address.trim(), name: nameOf(node) };
41
+ }
42
+ visit(node.children);
43
+ }
44
+ };
45
+ visit(evaluateUiBlock(block, state, limits).nodes);
46
+ return places;
47
+ }
package/src/runtime.ts CHANGED
@@ -21,6 +21,8 @@ export interface UiViewNode<Props = Record<string, unknown>> {
21
21
  complete: boolean;
22
22
  fallback: string;
23
23
  error?: string;
24
+ // How many children a container left out because it holds at most so many of its kind; absent when it left none out.
25
+ omitted?: number;
24
26
  // Runs the node's state action; present only on a component that has one.
25
27
  onAction?(): void;
26
28
  }
@@ -239,6 +241,30 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
239
241
  };
240
242
  }
241
243
  view.children = children(node.children, variables, depth + 1, suffix);
244
+ if ('count' in entry) {
245
+ const limit = entry.count as { min: number; max?: number };
246
+ const kinds = entry.children as readonly string[];
247
+ const counted = view.children.filter((child) => kinds.includes(child.type)).length;
248
+ // What an Each or a Show drew is only known now, so the count the compiler checked on the source is checked again.
249
+ if (node.complete && counted < limit.min) {
250
+ throw new UiFailure('invalid_props', `${node.type} needs at least ${limit.min} of ${kinds.join(' or ')}.`);
251
+ }
252
+ if (limit.max !== undefined && counted > limit.max) {
253
+ let kept = 0;
254
+ view.children = view.children.filter((child) => !kinds.includes(child.type) || ++kept <= limit.max!);
255
+ view.omitted = counted - limit.max;
256
+ if (result.diagnostics.length < budget.limits.diagnostics) {
257
+ result.diagnostics.push(
258
+ uiDiagnostic(
259
+ new UiFailure('too_many_children', `${node.type} shows its first ${limit.max} of ${kinds.join(' or ')}.`),
260
+ node.start,
261
+ node.end,
262
+ node.id
263
+ )
264
+ );
265
+ }
266
+ }
267
+ }
242
268
  return [view];
243
269
  } catch (error) {
244
270
  if (error instanceof UiFailure && error.code === 'budget_exceeded') {
package/src/text.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { UI_CATALOG, UI_GROUPS } from './catalog.ts';
1
+ import { UI_CATALOG, UI_GROUPS, type UiGroupName } from './catalog.ts';
2
2
  import { uiPropList } from './prop-list.ts';
3
3
  import { UI_FENCE_LANGUAGE, type UiBlock } from './compiler.ts';
4
4
 
@@ -14,6 +14,10 @@ export function uiCompactCatalog(): string {
14
14
  export interface UiTextOptions {
15
15
  // The info string the host's compiler looks for; `UI_FENCE_LANGUAGE` without one.
16
16
  fenceLanguage?: string;
17
+ // What the session note lists: every component with its props, or only the names per group.
18
+ catalog?: 'props' | 'names';
19
+ // With `names`, how the agent gets the props of a group, in the host's own words.
20
+ detailHint?: string;
17
21
  }
18
22
 
19
23
  function uiSyntax(language: string): string {
@@ -31,22 +35,37 @@ function uiExample(language: string): string {
31
35
  ].join('\n');
32
36
  }
33
37
 
34
- export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {
35
- 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 are shortcuts after finished work that the person may ignore. When your turn would end waiting on the person (a missing fact, a decision, a go-ahead for work you would otherwise do), ask with your own question tool if you have one, otherwise in prose, never with Choices. 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`;
38
+ function uiGroupComponents(group: string) {
39
+ return Object.entries(UI_CATALOG).filter(([, entry]) => entry.group === group);
40
+ }
41
+
42
+ /* One line per group with the names of its components, for a host that hands the props out per group. */
43
+ function uiGroupNames(detailHint: string | undefined): string {
44
+ const lines = Object.keys(UI_GROUPS).map(
45
+ (group) =>
46
+ `${group}: ${uiGroupComponents(group)
47
+ .map(([name]) => name)
48
+ .join(', ')}`
49
+ );
50
+ return `Components by group${detailHint ? ` (${detailHint})` : ''}:\n${lines.join('\n')}`;
51
+ }
52
+
53
+ export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE, catalog = 'props', detailHint }: UiTextOptions = {}): string {
54
+ const components = catalog === 'names' ? uiGroupNames(detailHint) : `Catalog (? means optional props): ${uiCompactCatalog()}`;
55
+ 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 are shortcuts after finished work that the person may ignore. When your turn would end waiting on the person (a missing fact, a decision, a go-ahead for work you would otherwise do), ask with your own question tool if you have one, otherwise in prose, never with Choices. 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. ${components}\nExample:\n${uiExample(fenceLanguage)}\n`;
56
+ }
57
+
58
+ /* One group as the reference writes it: its description and rules, then every component with its props. */
59
+ export function uiGroupReference(group: UiGroupName): string {
60
+ const components = uiGroupComponents(group).map(([name, entry]) => {
61
+ const props = uiPropList(entry).join(', ');
62
+ return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
63
+ });
64
+ return [UI_GROUPS[group].description, UI_GROUPS[group].rules, ...components].join('\n');
36
65
  }
37
66
 
38
67
  export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {
39
- const groups = Object.entries(UI_GROUPS)
40
- .map(([group, definition]) => {
41
- const components = Object.entries(UI_CATALOG)
42
- .filter(([, entry]) => entry.group === group)
43
- .map(([name, entry]) => {
44
- const props = uiPropList(entry).join(', ');
45
- return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
46
- });
47
- return [definition.description, definition.rules, ...components].join('\n');
48
- })
49
- .join('\n\n');
68
+ const groups = (Object.keys(UI_GROUPS) as UiGroupName[]).map(uiGroupReference).join('\n\n');
50
69
  return `${uiSyntax(fenceLanguage)}\nExample:\n${uiExample(fenceLanguage)}\n\n${groups}`;
51
70
  }
52
71