@adecore/intelligent-ui 0.21.2 → 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 +3 -3
- package/dist/catalog.js +3 -3
- package/dist/compiler.js +38 -1
- package/dist/fallback.js +31 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +19 -8
- package/dist/syntax.d.ts +1 -0
- package/dist/syntax.js +131 -61
- package/dist/text.js +1 -1
- package/package.json +1 -1
- package/src/catalog.ts +3 -3
- package/src/compiler.ts +46 -1
- package/src/fallback.ts +32 -7
- package/src/index.ts +1 -0
- package/src/runtime.ts +22 -9
- package/src/syntax.ts +127 -58
- package/src/text.ts +1 -1
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
11
|
-
return `${
|
|
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 '
|
|
15
|
-
return `${uiPlainText(props.
|
|
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
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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
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
|
|
216
|
-
|
|
217
|
-
if (
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
281
|
+
throw new UiFailure('invalid_prop', 'Use a quoted string or a braced expression.');
|
|
236
282
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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 (
|
|
263
|
-
|
|
264
|
-
|
|
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 (
|
|
281
|
-
|
|
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
|
-
|
|
316
|
+
value = this.source.slice(valueStart, this.position);
|
|
284
317
|
}
|
|
285
|
-
node.
|
|
286
|
-
|
|
287
|
-
|
|
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
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
12
|
-
return `${
|
|
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 '
|
|
16
|
-
return `${uiPlainText(props.
|
|
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
|
|
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
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
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
if (
|
|
244
|
+
const propStart = this.position;
|
|
245
|
+
try {
|
|
246
|
+
if (!this.prop(node)) {
|
|
239
247
|
return;
|
|
240
248
|
}
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
value = this.source.slice(valueStart, this.position);
|
|
301
336
|
}
|
|
302
|
-
node.
|
|
303
|
-
|
|
304
|
-
|
|
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 {
|