pi-openui 0.0.0-stage → 0.1.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.
@@ -0,0 +1,613 @@
1
+ // Component signatures, validation and normalisation of OpenUI Lang programs.
2
+ // One table drives: argument checking, the typed element tree the renderers read, and the reference text for the model.
3
+
4
+ import { markdownImages } from "./md.ts";
5
+ import { parseProgram, type Expr } from "./parse.ts";
6
+
7
+ export type T =
8
+ | "string"
9
+ | "number"
10
+ | "boolean"
11
+ | "any"
12
+ | "action"
13
+ | "cell"
14
+ | "child"
15
+ | { enum: readonly string[] }
16
+ | { arr: T; min?: number; max?: number }
17
+ | { comp: readonly string[] };
18
+
19
+ export interface Param {
20
+ name: string;
21
+ t: T;
22
+ opt?: boolean;
23
+ /** most characters in this string argument (the default is LIMITS.string) */
24
+ max?: number;
25
+ }
26
+ export interface CompSpec {
27
+ name: string;
28
+ params: Param[];
29
+ desc: string;
30
+ /** only valid inside a parent (its own argument slot) */
31
+ helper?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Size limits. A widget is drawn again whenever pi redraws, and a model can write far more than a terminal can show, so each
36
+ * limit is a validation error with a line number: the model fixes it and nothing oversized is ever laid out. Sized for a
37
+ * terminal of about 100 columns; the stress tests (test/limits.test.ts) draw every component at its limit.
38
+ */
39
+ export const LIMITS = {
40
+ /** characters of OpenUI Lang in one widget */
41
+ source: 100_000,
42
+ /** errors reported at once */
43
+ errors: 25,
44
+ /** components nested inside each other */
45
+ depth: 16,
46
+ /** components in one widget */
47
+ elements: 1_500,
48
+ /** characters in one string, unless the argument says otherwise */
49
+ string: 2_000,
50
+ /** characters in one table cell */
51
+ cell: 300,
52
+ text: 8_000,
53
+ markdown: 30_000,
54
+ code: 30_000,
55
+ codeLines: 400,
56
+ answer: 4_000,
57
+ /** an Image src: a path, or a small data: URI. Larger pictures belong in a file */
58
+ src: 100_000,
59
+ children: 40,
60
+ tabs: 12,
61
+ tableRows: 100,
62
+ tableCols: 16,
63
+ bars: 40,
64
+ points: 200,
65
+ series: 8,
66
+ axes: 16,
67
+ dagNodes: 60,
68
+ dagEdges: 150,
69
+ entities: 20,
70
+ fields: 30,
71
+ relations: 40,
72
+ zones: 8,
73
+ zoneNodes: 12,
74
+ /** nodes and links in one InfraMap: every link is a route search, and the cost climbs steeply past about 30 nodes */
75
+ infraNodes: 30,
76
+ infraLinks: 45,
77
+ /** links on a GeoMap */
78
+ links: 60,
79
+ geoPoints: 100,
80
+ faq: 30,
81
+ steps: 100,
82
+ /** pictures in one widget: Image components and `![alt](path)` lines together. Each can be megabytes */
83
+ images: 12,
84
+ } as const;
85
+
86
+ const S: T = "string";
87
+ const strs: T = { arr: S };
88
+ const kids: T = { arr: "child", max: LIMITS.children };
89
+ const tips: T = { arr: { comp: ["Tooltip"] } };
90
+ const p = (name: string, t: T, opt = false, max?: number): Param => ({ name, t, opt, ...(max ? { max } : {}) });
91
+ const e = (...v: string[]): T => ({ enum: v });
92
+
93
+ export const SPECS: CompSpec[] = [
94
+ { name: "Stack", params: [p("children", kids), p("direction", e("row", "column"), true), p("gap", e("none", "xs", "s", "m", "l", "xl"), true), p("wrap", "boolean", true)], desc: 'Layout container. direction "column" (default) or "row". gap none|xs|s|m|l|xl (default m). wrap lets a row wrap.' },
95
+ { name: "Text", params: [p("text", S, false, LIMITS.text), p("variant", e("title", "heading", "body", "muted", "code"), true)], desc: "A line or paragraph of text (default body)." },
96
+ { name: "Card", params: [p("children", kids), p("title", S, true)], desc: "Bordered panel with an optional title." },
97
+ { name: "Metric", params: [p("label", S), p("value", S), p("delta", S, true), p("tone", e("good", "warn", "bad", "neutral"), true), p("tooltips", tips, true)], desc: "KPI tile. value and delta are strings. tone colours the delta." },
98
+ { name: "Table", params: [p("columns", { arr: S, max: LIMITS.tableCols }), p("rows", { arr: { arr: "cell", max: LIMITS.tableCols }, max: LIMITS.tableRows }), p("tooltips", tips, true)], desc: "Data table. rows is an array of rows, each the same length as columns (strings or numbers)." },
99
+ { name: "BarChart", params: [p("labels", { arr: S, max: LIMITS.bars }), p("values", { arr: "number", max: LIMITS.bars }), p("title", S, true), p("unit", S, true), p("tooltips", tips, true)], desc: "Bar chart of one series. labels and values are parallel arrays. unit is a suffix such as %." },
100
+ { name: "Callout", params: [p("text", S), p("tone", e("info", "success", "warn", "error"), true)], desc: "Highlighted note." },
101
+ { name: "Button", params: [p("label", S), p("action", "action", true)], desc: "Clickable button. Without an action it sends its label to the assistant." },
102
+ { name: "TabItem", helper: true, params: [p("label", S), p("content", kids)], desc: "One tab. Only valid inside Tabs." },
103
+ { name: "Tabs", params: [p("items", { arr: { comp: ["TabItem"] }, min: 1, max: LIMITS.tabs })], desc: "Tabbed container." },
104
+ { name: "Markdown", params: [p("text", S, false, LIMITS.markdown)], desc: "Rich text from markdown: headings, **bold**, *italic*, `code`, lists, > quotes, tables, fenced code, ---. A line that is only ![alt](path \"caption\") draws a picture (a local file or data: URI, as for Image)." },
105
+ { name: "CodeBlock", params: [p("code", S, false, LIMITS.code), p("language", S, true), p("title", S, true)], desc: "Syntax highlighted code. language: python|js|ts|bash|sql|yaml|json|go|rust (others unhighlighted). Click copies it." },
106
+ { name: "Image", params: [p("src", S, false, LIMITS.src), p("alt", S, true), p("caption", S, true), p("tooltips", tips, true)], desc: "An image. A terminal grid cannot hold bitmaps, so it is drawn as a labelled frame with the file name and size." },
107
+ { name: "DagNode", helper: true, params: [p("id", S), p("label", S), p("status", e("done", "running", "failed", "pending", "skipped"), true)], desc: "One DAG node." },
108
+ { name: "Dag", params: [p("nodes", { arr: { comp: ["DagNode"] }, min: 1, max: LIMITS.dagNodes }), p("edges", { arr: { arr: S }, max: LIMITS.dagEdges }), p("direction", e("LR", "TB"), true), p("title", S, true), p("tooltips", tips, true)], desc: "Directed graph with automatic layered layout. edges are [fromId, toId] pairs. direction omitted = automatic." },
109
+ { name: "Entity", helper: true, params: [p("name", S), p("fields", { arr: S, max: LIMITS.fields })], desc: 'ERD table. fields are strings like "id uuid PK", "user_id uuid FK", "email text UNIQUE".' },
110
+ { name: "Relation", helper: true, params: [p("from", S), p("to", S), p("kind", e("1:1", "1:N", "N:1", "N:M"), true), p("label", S, true)], desc: "ERD relation between two entity names. 1:N (default): the first entity is the one side." },
111
+ { name: "Erd", params: [p("entities", { arr: { comp: ["Entity"] }, min: 1, max: LIMITS.entities }), p("relations", { arr: { comp: ["Relation"] }, max: LIMITS.relations }), p("title", S, true), p("tooltips", tips, true)], desc: "Entity-relationship diagram with crow's-foot relations." },
112
+ { name: "GeoPoint", helper: true, params: [p("label", S), p("lat", "number"), p("lon", "number"), p("tone", e("good", "warn", "bad", "info", "neutral"), true)], desc: "Map pin in decimal degrees." },
113
+ { name: "GeoMap", params: [p("points", { arr: { comp: ["GeoPoint"] }, min: 1, max: LIMITS.geoPoints }), p("links", { arr: { arr: S }, max: LIMITS.links }, true), p("title", S, true), p("tooltips", tips, true)], desc: "Offline world map (coarse coastlines) zoomed to fit the pins. links are [labelA, labelB] pairs drawn dashed." },
114
+ { name: "FaqItem", helper: true, params: [p("question", S), p("answer", S, false, LIMITS.answer)], desc: "One Q&A entry (answer is markdown). Only valid inside Faq." },
115
+ { name: "Faq", params: [p("items", { arr: { comp: ["FaqItem"] }, min: 1, max: LIMITS.faq }), p("firstOpen", "boolean", true)], desc: "Collapsible Q&A list." },
116
+ { name: "Choice", params: [p("question", S), p("options", { arr: S, min: 2, max: 6 }), p("hint", S, true)], desc: 'A question with clickable options for the USER. Clicking sends "<question> — <option>" to the assistant.' },
117
+ { name: "Series", helper: true, params: [p("category", S), p("values", { arr: "number", max: LIMITS.points })], desc: "One data series: name and numbers parallel to the chart labels." },
118
+ { name: "LineChart", params: [p("labels", { arr: S, max: LIMITS.points }), p("series", { arr: { comp: ["Series"] }, min: 1, max: LIMITS.series }), p("variant", e("linear", "natural", "step"), true), p("xLabel", S, true), p("yLabel", S, true), p("height", "number", true), p("tooltips", tips, true)], desc: "Line chart over categories, several series. variant natural (smooth, default), linear or step." },
119
+ { name: "AreaChart", params: [p("labels", { arr: S, max: LIMITS.points }), p("series", { arr: { comp: ["Series"] }, min: 1, max: LIMITS.series }), p("variant", e("linear", "natural", "step"), true), p("xLabel", S, true), p("yLabel", S, true), p("height", "number", true), p("tooltips", tips, true)], desc: "Filled STACKED area chart: series stack into a growing total." },
120
+ { name: "PieChart", params: [p("labels", strs), p("values", { arr: "number" }), p("variant", e("pie", "donut"), true), p("appearance", e("circular", "semiCircular"), true), p("tooltips", tips, true)], desc: "Parts of a whole, at most 8 slices. Values are raw numbers, not percentages." },
121
+ { name: "RadarChart", params: [p("labels", { arr: S, min: 3, max: LIMITS.axes }), p("series", { arr: { comp: ["Series"] }, min: 1, max: LIMITS.series }), p("tooltips", tips, true)], desc: "Radar chart: one axis per label, each series parallel to labels." },
122
+ { name: "InfraNode", helper: true, params: [p("id", S), p("label", S), p("kind", e("client", "gateway", "service", "gpu", "database", "storage", "queue", "cloud"), true), p("status", e("up", "degraded", "down", "unknown"), true), p("detail", S, true)], desc: "One box inside an InfraZone. id is unique across the map." },
123
+ { name: "InfraZone", helper: true, params: [p("name", S), p("nodes", { arr: { comp: ["InfraNode"] }, min: 1, max: LIMITS.zoneNodes }), p("tone", e("neutral", "info", "good", "warn", "bad"), true)], desc: "A labelled box that groups nodes." },
124
+ { name: "InfraLink", helper: true, params: [p("from", S), p("to", S), p("label", S, true), p("style", e("solid", "dashed"), true)], desc: "An arrow between two InfraNode ids." },
125
+ { name: "InfraMap", params: [p("zones", { arr: { comp: ["InfraZone"] }, min: 1, max: LIMITS.zones }), p("links", { arr: { comp: ["InfraLink"] }, max: LIMITS.infraLinks }), p("title", S, true), p("direction", e("LR", "TB"), true), p("tooltips", tips, true)], desc: "Infrastructure map: zones are columns (LR, default) or rows (TB)." },
126
+ { name: "SeqParticipant", helper: true, params: [p("id", S), p("label", S), p("kind", e("actor", "service", "database", "queue", "external"), true)], desc: "One column of a SequenceDiagram." },
127
+ { name: "SeqMessage", helper: true, params: [p("from", S), p("to", S), p("label", S), p("style", e("sync", "async", "return"), true), p("id", S, true)], desc: "An arrow between participant ids. from == to is a self-call." },
128
+ { name: "SeqNote", helper: true, params: [p("over", S), p("text", S), p("to", S, true)], desc: "A note over a participant, or spanning to another." },
129
+ { name: "SeqGroup", helper: true, params: [p("kind", e("alt", "opt", "loop", "par")), p("label", S), p("steps", { arr: { comp: ["SeqMessage", "SeqNote", "SeqGroup"] }, max: LIMITS.steps }), p("elseSteps", { arr: { comp: ["SeqMessage", "SeqNote", "SeqGroup"] }, max: LIMITS.steps }, true), p("elseLabel", S, true)], desc: "A frame around steps. elseSteps adds a dashed divider and a second branch." },
130
+ { name: "SequenceDiagram", params: [p("participants", { arr: { comp: ["SeqParticipant"] }, min: 1, max: 10 }), p("steps", { arr: { comp: ["SeqMessage", "SeqNote", "SeqGroup"] }, max: LIMITS.steps }), p("title", S, true), p("numbered", "boolean", true), p("tooltips", tips, true)], desc: "Calls between participants: columns left to right, steps top to bottom." },
131
+ { name: "Tooltip", helper: true, params: [p("target", S), p("hint", S, true), p("detail", S, true), p("code", S, true), p("language", S, true), p("tone", e("info", "good", "warn", "bad"), true), p("link", S, true), p("linkLabel", S, true)], desc: "Hover hint with an optional click-to-open panel for one element of the host component." },
132
+ ];
133
+
134
+ export const LIB = new Map(SPECS.map((s) => [s.name, s]));
135
+ export const TOP_LEVEL = SPECS.filter((s) => !s.helper).map((s) => s.name);
136
+
137
+ /** What each Tooltip host accepts as `target`. Kept in one place so validation, rendering and the reference agree. */
138
+ export const TARGET_HELP: Record<string, string> = {
139
+ Dag: "Use a DagNode id.",
140
+ Erd: "Use an Entity name or entity.column.",
141
+ InfraMap: "Use an InfraNode id, zone:<zone name>, or <fromId>><toId> for a link.",
142
+ SequenceDiagram: "Use a SeqParticipant id or a SeqMessage id (a message needs an id to carry a tooltip).",
143
+ Table: "Use row:N (1-based), col:<header> or cell:N.<header>.",
144
+ Metric: "Use the metric's own label.",
145
+ BarChart: "Use a bar label.",
146
+ GeoMap: "Use a GeoPoint label, or A>B for a link.",
147
+ Image: "Use the word image.",
148
+ LineChart: "Use an x label (a category) or series:<name>.",
149
+ AreaChart: "Use an x label (a category) or series:<name>.",
150
+ PieChart: "Use a slice label.",
151
+ RadarChart: "Use an axis label or series:<name>.",
152
+ };
153
+
154
+ export function tipTargets(el: El): string[] {
155
+ const pr = el.props;
156
+ const names = (xs: El[] | undefined, key: string): string[] => (xs ?? []).map((x) => String(x.props?.[key] ?? "")).filter(Boolean);
157
+ switch (el.type) {
158
+ case "Dag":
159
+ return names(pr.nodes, "id");
160
+ case "Erd":
161
+ return (pr.entities ?? []).flatMap((e: El) => [String(e.props.name), ...((e.props.fields ?? []) as string[]).map((f) => `${e.props.name}.${String(f).trim().split(/\s+/)[0]}`)]);
162
+ case "InfraMap":
163
+ return [...(pr.zones ?? []).flatMap((z: El) => [`zone:${z.props.name}`, ...names(z.props.nodes, "id")]), ...(pr.links ?? []).map((l: El) => `${l.props.from}>${l.props.to}`)];
164
+ case "SequenceDiagram": {
165
+ const ids = names(pr.participants, "id");
166
+ const walk = (steps: El[] | undefined) => {
167
+ for (const s of steps ?? []) {
168
+ if (s.type === "SeqMessage" && s.props.id) ids.push(String(s.props.id));
169
+ else if (s.type === "SeqGroup") {
170
+ walk(s.props.steps);
171
+ walk(s.props.elseSteps);
172
+ }
173
+ }
174
+ };
175
+ walk(pr.steps);
176
+ return ids;
177
+ }
178
+ case "Table": {
179
+ const cols = ((pr.columns ?? []) as string[]).map(String);
180
+ const rows = (pr.rows ?? []) as unknown[][];
181
+ return [...rows.map((_, i) => `row:${i + 1}`), ...cols.map((c) => `col:${c}`), ...rows.flatMap((_, i) => cols.map((c) => `cell:${i + 1}.${c}`))];
182
+ }
183
+ case "Metric":
184
+ return [String(pr.label ?? "")];
185
+ case "BarChart":
186
+ case "PieChart":
187
+ return ((pr.labels ?? []) as string[]).map(String);
188
+ case "GeoMap":
189
+ return [...names(pr.points, "label"), ...(pr.links ?? []).map((l: string[]) => `${l[0]}>${l[1]}`)];
190
+ case "Image":
191
+ return ["image"];
192
+ case "LineChart":
193
+ case "AreaChart":
194
+ case "RadarChart":
195
+ return [...((pr.labels ?? []) as string[]).map(String), ...names(pr.series, "category").map((c) => `series:${c}`)];
196
+ }
197
+ return [];
198
+ }
199
+
200
+ export interface El {
201
+ type: string;
202
+ props: Record<string, any>;
203
+ line: number;
204
+ /** The variable this element was defined under (`tbl` in `tbl = Table(...)`). Absent for an element written inline. */
205
+ name?: string;
206
+ }
207
+ export interface Action {
208
+ steps: Array<{ name: string; args: any[] }>;
209
+ }
210
+
211
+ export interface Compiled {
212
+ ok: boolean;
213
+ errors: string[];
214
+ warnings: string[];
215
+ root?: El;
216
+ }
217
+
218
+ export function showType(t: T): string {
219
+ if (typeof t === "string") return t === "child" ? "component" : t === "cell" ? "string | number" : t;
220
+ if ("enum" in t) return t.enum.map((x) => JSON.stringify(x)).join(" | ");
221
+ if ("arr" in t) {
222
+ const inner = showType(t.arr);
223
+ return (inner.includes(" ") && !inner.startsWith("Tooltip") ? `(${inner})` : inner) + "[]";
224
+ }
225
+ return t.comp.join(" | ");
226
+ }
227
+
228
+ /** Reads and checks a program. Never throws: whatever goes wrong comes back as an error the model can read. */
229
+ export function compile(source: string): Compiled {
230
+ try {
231
+ const text = String(source ?? "");
232
+ if (text.length > LIMITS.source) return { ok: false, errors: [`the source is ${Math.round(text.length / 1000)} KB; the limit is ${LIMITS.source / 1000} KB. Show less data, or split it into several widgets`], warnings: [] };
233
+ const c = compileProgram(text);
234
+ if (c.errors.length > LIMITS.errors) {
235
+ const more = c.errors.length - LIMITS.errors;
236
+ return { ...c, errors: [...c.errors.slice(0, LIMITS.errors), `... and ${more} more error${more === 1 ? "" : "s"}; fix these first`], root: undefined, ok: false };
237
+ }
238
+ return c;
239
+ } catch (e) {
240
+ return { ok: false, errors: [`the widget could not be read: ${(e as Error).message}. Make it smaller and less nested`], warnings: [] };
241
+ }
242
+ }
243
+
244
+ function compileProgram(source: string): Compiled {
245
+ const prog = parseProgram(source);
246
+ const errors = [...prog.errors];
247
+ const warnings: string[] = [];
248
+ if (!prog.statements.has("root")) {
249
+ errors.push("define the entry point: root = Stack([...]) (put it on the first line)");
250
+ return { ok: false, errors, warnings };
251
+ }
252
+ if (prog.order[0] !== "root") warnings.push("root is not the first statement; put it first so the shell appears immediately");
253
+
254
+ // the variable each call was defined under, so a note can say `tbl` instead of "a table"
255
+ const nameOf = new Map<Expr, string>();
256
+ for (const [stmtName, st] of prog.statements) if (st.expr.k === "call") nameOf.set(st.expr, stmtName);
257
+ const used = new Set<string>(["root"]);
258
+ const visiting: string[] = [];
259
+ const where = (line: number) => `line ${line}: `;
260
+
261
+ const deref = (x: Expr): Expr | null => {
262
+ let cur = x;
263
+ const chain: string[] = [];
264
+ while (cur.k === "ref") {
265
+ const st = prog.statements.get(cur.name);
266
+ if (!st) {
267
+ errors.push(`${where(cur.line)}"${cur.name}" is used but never defined`);
268
+ return null;
269
+ }
270
+ if (chain.includes(cur.name) || visiting.includes(cur.name)) {
271
+ errors.push(`${where(cur.line)}"${cur.name}" refers to itself (a cycle)`);
272
+ return null;
273
+ }
274
+ chain.push(cur.name);
275
+ used.add(cur.name);
276
+ cur = st.expr;
277
+ }
278
+ return cur;
279
+ };
280
+ // a reference to a component: track it while building so a cycle is detected
281
+ const withVisit = <R>(x: Expr, f: () => R): R => {
282
+ if (x.k === "ref") visiting.push(x.name);
283
+ try {
284
+ return f();
285
+ } finally {
286
+ if (x.k === "ref") visiting.pop();
287
+ }
288
+ };
289
+
290
+ const label = (x: Expr) => (x.k === "call" ? x.name : x.k === "str" ? "a string" : x.k === "num" ? "a number" : x.k === "bool" ? "a boolean" : x.k === "arr" ? "an array" : x.k === "obj" ? "an object" : x.k === "null" ? "null" : x.k === "action" ? `@${x.name}` : "a reference");
291
+
292
+ const plain = (x: Expr): any => {
293
+ const d = deref(x);
294
+ if (!d) return null;
295
+ switch (d.k) {
296
+ case "str":
297
+ case "num":
298
+ case "bool":
299
+ return d.v;
300
+ case "null":
301
+ return null;
302
+ case "arr":
303
+ return d.items.map(plain);
304
+ case "obj":
305
+ return Object.fromEntries(d.entries.map(([k, v]) => [k, plain(v)]));
306
+ default:
307
+ return null;
308
+ }
309
+ };
310
+
311
+ const tooLong = (v: string, max: number, ctx: string, line: number) => {
312
+ if (v.length > max) errors.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}. Shorten it`);
313
+ };
314
+ const resolve = (x: Expr, t: T, ctx: string, maxChars: number = LIMITS.string): any => {
315
+ const d0 = x;
316
+ const d = deref(x);
317
+ if (!d) return undefined;
318
+ return withVisit(d0, () => {
319
+ const bad = (want: string) => {
320
+ errors.push(`${where(d.line)}${ctx} expects ${want} but got ${label(d)}`);
321
+ return undefined;
322
+ };
323
+ if (typeof t === "string") {
324
+ switch (t) {
325
+ case "string":
326
+ if (d.k !== "str") return bad("a string");
327
+ tooLong(d.v, maxChars, ctx, d.line);
328
+ return d.v;
329
+ case "number":
330
+ return d.k === "num" ? d.v : bad("a number");
331
+ case "boolean":
332
+ return d.k === "bool" ? d.v : bad("true or false");
333
+ case "cell":
334
+ if (d.k !== "str" && d.k !== "num") return bad("a string or a number");
335
+ if (d.k === "str") tooLong(d.v, LIMITS.cell, ctx, d.line);
336
+ return d.v;
337
+ case "any":
338
+ return plain(d);
339
+ case "action": {
340
+ const steps: Action["steps"] = [];
341
+ const add = (a: Expr) => {
342
+ const da = deref(a);
343
+ if (!da) return;
344
+ if (da.k === "action") {
345
+ if (da.name === "ToAssistant") {
346
+ const msg = da.args[0] ? plain(da.args[0]) : undefined;
347
+ if (typeof msg !== "string" || !msg) errors.push(`${where(da.line)}@ToAssistant needs a message string`);
348
+ else {
349
+ if (msg.length > 500) errors.push(`${where(da.line)}@ToAssistant messages are limited to 500 characters (this one has ${msg.length})`);
350
+ steps.push({ name: "ToAssistant", args: [msg, da.args[1] ? plain(da.args[1]) : undefined] });
351
+ }
352
+ } else if (da.name === "OpenUrl") {
353
+ const url = da.args[0] ? plain(da.args[0]) : undefined;
354
+ if (typeof url !== "string") errors.push(`${where(da.line)}@OpenUrl needs a url string`);
355
+ else steps.push({ name: "OpenUrl", args: [url] });
356
+ } else errors.push(`${where(da.line)}unknown action step @${da.name} (use @ToAssistant or @OpenUrl)`);
357
+ } else if (da.k === "call" && da.name === "Action") {
358
+ const inner = da.args[0] ? deref(da.args[0]) : null;
359
+ if (!inner || inner.k !== "arr") errors.push(`${where(da.line)}Action needs an array of steps: Action([@ToAssistant("...")])`);
360
+ else inner.items.forEach(add);
361
+ } else errors.push(`${where(da.line)}an action must be @ToAssistant("..."), @OpenUrl("...") or Action([...])`);
362
+ };
363
+ add(d);
364
+ return { steps } satisfies Action;
365
+ }
366
+ case "child": {
367
+ if (d.k !== "call") return bad("a component, such as Text(...) or Card(...)");
368
+ const spec = LIB.get(d.name);
369
+ if (spec?.helper) {
370
+ errors.push(`${where(d.line)}${d.name} is not a standalone component: it only belongs inside its parent (${helperParent(d.name)}). Put the layout component there instead`);
371
+ return undefined;
372
+ }
373
+ return build(d);
374
+ }
375
+ }
376
+ }
377
+ if ("enum" in t) {
378
+ if (d.k !== "str") return bad(`one of ${t.enum.map((v) => JSON.stringify(v)).join(", ")}`);
379
+ if (!t.enum.includes(d.v)) {
380
+ errors.push(`${where(d.line)}${ctx} must be one of ${t.enum.map((v) => JSON.stringify(v)).join(", ")}, not ${JSON.stringify(d.v)}`);
381
+ return undefined;
382
+ }
383
+ return d.v;
384
+ }
385
+ if ("arr" in t) {
386
+ if (d.k !== "arr") return bad("an array [...]");
387
+ const out = d.items.map((it, i) => resolve(it, t.arr, `${ctx}[${i + 1}]`)).filter((v) => v !== undefined);
388
+ if (t.min !== undefined && d.items.length < t.min) errors.push(`${where(d.line)}${ctx} needs at least ${t.min} item(s)`);
389
+ if (t.max !== undefined && d.items.length > t.max) errors.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}`);
390
+ return out;
391
+ }
392
+ // { comp: [...] }
393
+ if (d.k !== "call") return bad(t.comp.join(" or "));
394
+ if (!t.comp.includes(d.name)) {
395
+ errors.push(`${where(d.line)}${ctx} expects ${t.comp.join(" or ")} but got ${d.name}`);
396
+ return undefined;
397
+ }
398
+ return build(d);
399
+ });
400
+ };
401
+
402
+ const helperParent = (name: string): string => {
403
+ const parents = SPECS.filter((s) => s.params.some((q) => JSON.stringify(q.t).includes(`"${name}"`))).map((s) => s.name);
404
+ return parents.join(" or ") || "its parent";
405
+ };
406
+
407
+ let depth = 0;
408
+ let built = 0;
409
+ let pictures = 0;
410
+ const build = (call: Extract<Expr, { k: "call" }>): El | undefined => {
411
+ const spec = LIB.get(call.name);
412
+ if (!spec) {
413
+ errors.push(`${where(call.line)}unknown component "${call.name}". Components: ${TOP_LEVEL.join(", ")}`);
414
+ return undefined;
415
+ }
416
+ if (depth >= LIMITS.depth) {
417
+ errors.push(`${where(call.line)}${spec.name} is nested more than ${LIMITS.depth} components deep. Flatten the layout`);
418
+ return undefined;
419
+ }
420
+ if (++built === LIMITS.elements + 1) errors.push(`${where(call.line)}the widget has more than ${LIMITS.elements} components. Show less, or split it into several widgets`);
421
+ if (built > LIMITS.elements) return undefined;
422
+ depth++;
423
+ try {
424
+ return buildInner(call, spec);
425
+ } finally {
426
+ depth--;
427
+ }
428
+ };
429
+ const buildInner = (call: Extract<Expr, { k: "call" }>, spec: CompSpec): El | undefined => {
430
+ if (call.args.length > spec.params.length) errors.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}`);
431
+ const props: Record<string, any> = {};
432
+ spec.params.forEach((param, i) => {
433
+ const arg = call.args[i];
434
+ const d = arg ? deref(arg) : null;
435
+ if (!arg || (d && d.k === "null")) {
436
+ if (!param.opt) errors.push(`${where(call.line)}${spec.name} is missing the required argument "${param.name}" (position ${i + 1})`);
437
+ return;
438
+ }
439
+ props[param.name] = resolve(arg, param.t, `${spec.name} argument "${param.name}"`, param.max);
440
+ });
441
+ const el: El = { type: spec.name, props, line: call.line, ...(nameOf.has(call) ? { name: nameOf.get(call) } : {}) };
442
+ crossCheck(el);
443
+ return el;
444
+ };
445
+
446
+ const checkTips = (el: El) => {
447
+ const tipsList = (el.props.tooltips ?? []) as El[];
448
+ if (!tipsList.length) return;
449
+ const valid = tipTargets(el);
450
+ const err = (line: number, m: string) => errors.push(`${where(line)}${el.type}: ${m}`);
451
+ const seenTargets = new Set<string>();
452
+ for (const t of tipsList) {
453
+ const pr = t.props;
454
+ const target = String(pr.target ?? "");
455
+ if (!valid.includes(target)) {
456
+ const shown = valid.slice(0, 14).join(", ") + (valid.length > 14 ? ", ..." : "");
457
+ err(t.line, `Tooltip target "${target}" matches nothing here. ${TARGET_HELP[el.type] ?? ""} Valid targets: ${shown || "(none)"}`.replace(" ", " "));
458
+ }
459
+ if (seenTargets.has(target)) err(t.line, `two Tooltips aim at "${target}"; use one`);
460
+ seenTargets.add(target);
461
+ if (typeof pr.hint === "string" && pr.hint.length > 140) err(t.line, `Tooltip hint is ${pr.hint.length} characters; keep it to 140 or move the rest into detail`);
462
+ if (typeof pr.detail === "string" && pr.detail.length > 800) err(t.line, `Tooltip detail is ${pr.detail.length} characters; keep it to 800`);
463
+ if (typeof pr.code === "string" && pr.code.split("\n").length > 40) err(t.line, "Tooltip code has more than 40 lines");
464
+ if (typeof pr.link === "string" && !/^https?:\/\//i.test(pr.link)) err(t.line, "Tooltip link must start with http:// or https://");
465
+ }
466
+ if (tipsList.length > 12) errors.push(`${where(el.line)}${el.type}: ${tipsList.length} Tooltips is a lot; use about 8 at most`);
467
+ };
468
+
469
+ const crossCheck = (el: El) => {
470
+ const pr = el.props;
471
+ const err = (m: string) => errors.push(`${where(el.line)}${el.type}: ${m}`);
472
+ checkTips(el);
473
+ // pictures: an Image, or lines of `![alt](path)` in a Markdown or an FAQ answer
474
+ const mdText = el.type === "Markdown" ? pr.text : el.type === "FaqItem" ? pr.answer : undefined;
475
+ const here = el.type === "Image" ? 1 : mdText !== undefined ? markdownImages(mdText).length : 0;
476
+ if (here) {
477
+ const before = pictures;
478
+ pictures += here;
479
+ if (before <= LIMITS.images && pictures > LIMITS.images) err(`this is picture ${LIMITS.images + 1}: a widget has at most ${LIMITS.images} (Image components and ![alt](path) lines together). Show fewer`);
480
+ }
481
+ const dupes = (xs: string[]) => xs.filter((x, i) => xs.indexOf(x) !== i);
482
+ switch (el.type) {
483
+ case "Table":
484
+ if (Array.isArray(pr.columns) && Array.isArray(pr.rows))
485
+ pr.rows.forEach((r: any[], i: number) => {
486
+ if (r.length !== pr.columns.length) err(`row ${i + 1} has ${r.length} cells but there are ${pr.columns.length} columns`);
487
+ });
488
+ break;
489
+ case "BarChart":
490
+ case "PieChart":
491
+ if (Array.isArray(pr.labels) && Array.isArray(pr.values) && pr.labels.length !== pr.values.length) err(`labels (${pr.labels.length}) and values (${pr.values.length}) must be the same length`);
492
+ if (el.type === "PieChart" && Array.isArray(pr.values)) {
493
+ if (pr.values.length > 8) err("use at most 8 slices");
494
+ if (pr.values.some((v: number) => v < 0)) err("values must not be negative");
495
+ }
496
+ break;
497
+ case "LineChart":
498
+ case "AreaChart":
499
+ case "RadarChart":
500
+ if (Array.isArray(pr.labels) && Array.isArray(pr.series))
501
+ for (const s of pr.series as El[]) if (s?.props?.values && s.props.values.length !== pr.labels.length) err(`series "${s.props.category}" has ${s.props.values.length} values but there are ${pr.labels.length} labels`);
502
+ break;
503
+ case "Dag": {
504
+ const ids: string[] = (pr.nodes ?? []).map((n: El) => n.props.id);
505
+ for (const d of dupes(ids)) err(`duplicate node id "${d}"`);
506
+ for (const ed of pr.edges ?? []) {
507
+ if (ed.length !== 2) err(`an edge must be [fromId, toId], got ${ed.length} item(s)`);
508
+ else for (const id of ed) if (!ids.includes(id)) err(`edge refers to "${id}", which is not a DagNode id. Use the id strings, not variable names. Ids: ${ids.join(", ")}`);
509
+ }
510
+ break;
511
+ }
512
+ case "Erd": {
513
+ const names: string[] = (pr.entities ?? []).map((n: El) => n.props.name);
514
+ for (const d of dupes(names)) err(`duplicate entity "${d}"`);
515
+ for (const r of (pr.relations ?? []) as El[]) for (const k of ["from", "to"]) if (r.props[k] && !names.includes(r.props[k])) err(`relation ${k} "${r.props[k]}" is not an Entity name. Names: ${names.join(", ")}`);
516
+ break;
517
+ }
518
+ case "CodeBlock":
519
+ if (typeof pr.code === "string" && pr.code.split("\n").length > LIMITS.codeLines) err(`the code has more than ${LIMITS.codeLines} lines; show the part that matters`);
520
+ break;
521
+ case "InfraMap": {
522
+ const ids: string[] = (pr.zones ?? []).flatMap((z: El) => (z.props.nodes ?? []).map((n: El) => n.props.id));
523
+ if (ids.length > LIMITS.infraNodes) err(`${ids.length} nodes in total; the limit is ${LIMITS.infraNodes}. Draw the parts that matter, or split it into several maps`);
524
+ for (const d of dupes(ids)) err(`node id "${d}" is used twice (ids are unique across the whole map)`);
525
+ for (const l of (pr.links ?? []) as El[]) for (const k of ["from", "to"]) if (l.props[k] && !ids.includes(l.props[k])) err(`link ${k} "${l.props[k]}" is not an InfraNode id. Ids: ${ids.join(", ")}`);
526
+ break;
527
+ }
528
+ case "GeoMap": {
529
+ const labels: string[] = (pr.points ?? []).map((n: El) => n.props.label);
530
+ for (const d of dupes(labels)) err(`duplicate point label "${d}"`);
531
+ for (const l of pr.links ?? []) for (const x of l) if (!labels.includes(x)) err(`link refers to "${x}", which is not a GeoPoint label. Labels: ${labels.join(", ")}`);
532
+ for (const pt of (pr.points ?? []) as El[]) {
533
+ if (pt.props.lat < -90 || pt.props.lat > 90) err(`latitude of "${pt.props.label}" must be between -90 and 90`);
534
+ if (pt.props.lon < -180 || pt.props.lon > 180) err(`longitude of "${pt.props.label}" must be between -180 and 180`);
535
+ }
536
+ break;
537
+ }
538
+ case "SequenceDiagram": {
539
+ const ids: string[] = (pr.participants ?? []).map((n: El) => n.props.id);
540
+ for (const d of dupes(ids)) err(`duplicate participant id "${d}"`);
541
+ const walk = (steps: El[]) => {
542
+ for (const s of steps ?? []) {
543
+ if (s.type === "SeqMessage") {
544
+ for (const k of ["from", "to"]) if (!ids.includes(s.props[k])) err(`message ${k} "${s.props[k]}" is not a SeqParticipant id. Ids: ${ids.join(", ")}`);
545
+ } else if (s.type === "SeqNote") {
546
+ for (const k of ["over", "to"]) if (s.props[k] && !ids.includes(s.props[k])) err(`note ${k} "${s.props[k]}" is not a SeqParticipant id. Ids: ${ids.join(", ")}`);
547
+ } else if (s.type === "SeqGroup") {
548
+ walk(s.props.steps);
549
+ walk(s.props.elseSteps);
550
+ }
551
+ }
552
+ };
553
+ walk(pr.steps);
554
+ break;
555
+ }
556
+ }
557
+ };
558
+
559
+ const rootStmt = prog.statements.get("root")!;
560
+ visiting.push("root");
561
+ const rootEl = resolve(rootStmt.expr, "child", "root");
562
+ visiting.pop();
563
+ if (rootEl && rootEl.type !== "Stack") errors.push(`${where(rootStmt.line)}root must be a Stack(...), not ${rootEl.type}`);
564
+ for (const name of prog.order) if (!used.has(name)) errors.push(`${where(prog.statements.get(name)!.line)}"${name}" is defined but not reachable from root, so it would not render. Add it to its parent's children or items array`);
565
+ const seen = new Set<string>();
566
+ const uniq = errors.filter((m) => (seen.has(m) ? false : (seen.add(m), true)));
567
+ return { ok: uniq.length === 0 && !!rootEl, errors: uniq, warnings, root: uniq.length === 0 ? rootEl : undefined };
568
+ }
569
+
570
+ /** Text for the model: syntax rules, signatures, actions, tooltips, examples. */
571
+ export function reference(): string {
572
+ const sig = (s: CompSpec) => `${s.name}(${s.params.map((q) => `${q.name}${q.opt ? "?" : ""}: ${showType(q.t)}`).join(", ")}) — ${s.desc}`;
573
+ return `You write OpenUI Lang to draw an interactive widget in the terminal (unicode, drawn inline in the pi transcript). Pass it as the \`source\` of render_widget. Output only OpenUI Lang statements.
574
+
575
+ ## Syntax
576
+ 1. One statement per line: \`name = Expression\`. \`root = Stack([...])\` comes first.
577
+ 2. Expressions: "strings" (backslash escapes, use \\n for newlines), numbers, true/false, null, [arrays], {objects}, Component(args), @Step(args), references to other names.
578
+ 3. Arguments are POSITIONAL. Write Metric("CPU", "38%", "+2%", "good"), never delta: "+2%". Skip an optional argument in the middle with null.
579
+ 4. Every name except root must be referenced by another statement, or it is an error (it would not render). References may be used before they are defined.
580
+ 5. Helper components (TabItem, DagNode, Entity, Relation, Series, GeoPoint, FaqItem, Infra*, Seq*, Tooltip) belong only in their parent's argument.
581
+ 6. The widget is drawn in a terminal of about 100 columns: keep labels short, tables under about 12 rows.
582
+
583
+ ## Components
584
+ ${SPECS.map(sig).join("\n")}
585
+
586
+ ## Tooltips
587
+ \`tooltips: Tooltip[]\` is the LAST argument of Dag, Erd, InfraMap, SequenceDiagram, Table, Metric, BarChart, LineChart, AreaChart, PieChart, RadarChart, GeoMap and Image, so write null for each skipped optional argument before it, e.g. Dag(nodes, edges, null, null, [t1]).
588
+ Targets: Dag node id; Erd entity name or entity.column; InfraMap node id, zone:<zone name> or <fromId>><toId>; SequenceDiagram participant id or SeqMessage id; Table row:3, col:status or cell:3.status; Metric its own label; BarChart a bar label; GeoMap a point label or A>B; Image the word image; LineChart and AreaChart an x label (that point) or series:<name>; PieChart a slice label; RadarChart an axis label or series:<name>.
589
+ An element that has a tooltip shows a small ◆ on it. Hovering shows the hint; clicking (or Enter) opens the panel with detail, code and link. Use a tooltip for context the reader may want but should not have to read: why a point spiked, what a slice contains, what a node does.
590
+ hint is the short text shown on hover (140 characters). detail (800), code (40 lines) and link (http/https) open in a panel when the element is clicked.
591
+
592
+ ## Limits
593
+ A widget over a limit is not drawn, and the error says which. Source ${LIMITS.source / 1000} KB, ${LIMITS.elements} components, ${LIMITS.depth} levels of nesting, ${LIMITS.children} children per Stack or Card, ${LIMITS.tabs} tabs. Text ${LIMITS.text} characters, Markdown ${LIMITS.markdown}, any other string ${LIMITS.string}, a table cell ${LIMITS.cell}. Table ${LIMITS.tableRows} rows by ${LIMITS.tableCols} columns. BarChart ${LIMITS.bars} bars. LineChart and AreaChart ${LIMITS.points} points and ${LIMITS.series} series. RadarChart ${LIMITS.axes} axes. Dag ${LIMITS.dagNodes} nodes and ${LIMITS.dagEdges} edges. Erd ${LIMITS.entities} entities, ${LIMITS.fields} fields each, ${LIMITS.relations} relations. InfraMap ${LIMITS.infraNodes} nodes and ${LIMITS.infraLinks} links. GeoMap ${LIMITS.geoPoints} points and ${LIMITS.links} links. Faq ${LIMITS.faq} items. Pictures ${LIMITS.images} (Image components and markdown picture lines together). CodeBlock ${LIMITS.codeLines} lines. A SequenceDiagram ${LIMITS.steps} steps. Prefer far less: the reader sees one terminal screen.
594
+
595
+ ## Actions
596
+ Button("Restart", @ToAssistant("Restart the vllm service")) sends that text to the assistant as the user's next turn (500 characters at most). A Button without an action sends its label. Several steps: Action([@ToAssistant("...")]). Do not use @OpenUrl: put links in a Text.
597
+ Choice(question, options) asks the user; a click sends "<question> — <option>".
598
+
599
+ ## Choosing components
600
+ Metric for a number, Table for comparisons, BarChart for one series of categories, LineChart for trends with several series, AreaChart for stacked volume, PieChart for parts of a whole (8 slices at most), RadarChart for comparing entities across metrics, Dag for task graphs and pipelines, Erd for database schemas, InfraMap for how systems connect, SequenceDiagram for call flows, GeoMap for places, Tabs/Card to organise. Series values must have the same length as labels. All data is literal: no queries, no variables. Image shows a real picture in terminals that support it (Kitty, Ghostty, WezTerm, Warp, herdr): src is an absolute file path (png, jpg, gif, webp) or a data: URI.
601
+
602
+ ## Example
603
+ root = Stack([title, kpis, load, go])
604
+ title = Text("Cluster status", "title")
605
+ kpis = Stack([m1, m2, m3], "row")
606
+ m1 = Metric("GPU util", "87%", "+4% vs 1h ago", "warn")
607
+ m2 = Metric("Nodes up", "4 / 4", "all healthy", "good")
608
+ m3 = Metric("Queue", "12", "-3", "good")
609
+ load = Card([chart], "Load by node")
610
+ chart = BarChart(["spark-1", "spark-2", "spark-3", "spark-4"], [91, 84, 77, 62], null, "%")
611
+ go = Button("Show slowest requests", @ToAssistant("Show the 10 slowest vLLM requests from the last hour"))
612
+ `;
613
+ }