@agentable-ui/genui 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,66 +1,110 @@
1
1
  # @agentable-ui/genui
2
2
 
3
- Generative UI for Agentable: the agent describes what to render, you keep control of what can be rendered.
3
+ Render model-generated interfaces using components you allow. Use `Renderer` for text streamed by your application, or `AgentSurface` for an in-page agent that calls a `present` tool.
4
+
5
+ ## Install
4
6
 
5
7
  ```bash
6
8
  npm install @agentable-ui/genui @agentable-ui/ui @agentable-ui/react @agentable-ui/core
7
9
  ```
8
10
 
11
+ ## Render a program
12
+
13
+ This complete example renders a static program. Replace `program` with the accumulated text from your model stream and set `isStreaming` while chunks arrive.
14
+
9
15
  ```tsx
10
- import { AgentSurface } from "@agentable-ui/genui";
16
+ import { Renderer } from "@agentable-ui/genui";
11
17
  import { uiCatalog } from "@agentable-ui/ui";
18
+ import "@agentable-ui/ui/styles.css";
19
+
20
+ const program = `root = Card("Order summary", [total])
21
+ total = Stat("Revenue", "$48.2k")`;
12
22
 
13
- <AgentSurface components={uiCatalog} name="present" maxNodes={60} />;
23
+ export default function Summary() {
24
+ return <Renderer components={uiCatalog} response={program} isStreaming={false} />;
25
+ }
14
26
  ```
15
27
 
16
- That declares a `present` tool whose input schema is generated from the catalog. The agent sends a UI spec; the component tree it can produce is exactly the catalog you passed and nothing else.
28
+ `response` is the **full text received so far**, not just the newest chunk. Complete lines render as they arrive; interactions stay disabled until streaming finishes. `onError` receives structured parsing and validation issues. Your app owns model requests, credentials, and streaming transport.
17
29
 
18
- ## Why one tool
30
+ ## Give a browser agent a surface
19
31
 
20
- Exposing a tool per component burns the budget and lets the agent invent markup. A single `present` tool with a generated schema keeps the cost flat and the output constrained.
32
+ ```tsx
33
+ import { AgentableProvider } from "@agentable-ui/react";
34
+ import { AgentSurface } from "@agentable-ui/genui";
35
+ import { uiCatalog } from "@agentable-ui/ui";
36
+ import "@agentable-ui/ui/styles.css";
37
+
38
+ export default function App() {
39
+ return (
40
+ <AgentableProvider>
41
+ <AgentSurface components={uiCatalog} name="present" maxNodes={60} />
42
+ </AgentableProvider>
43
+ );
44
+ }
45
+ ```
21
46
 
22
- ## Validation
47
+ The surface declares one `present` tool with a schema derived from its currently allowed catalog. An agent can supply either a program or a JSON tree:
23
48
 
24
- `validateSpec(spec, catalog, allowed, opts)` runs before anything renders and rejects unknown components, bad prop shapes, specs deeper than `maxDepth` (default 6) and specs larger than `maxNodes` (default 60). Failures come back to the agent as structured issues it can correct, not as a thrown error.
49
+ ```json
50
+ {"program":"root = Card(\"Hello\")"}
51
+ ```
25
52
 
26
- - `catalogSchema(catalog, allowed)` — the generated JSON Schema for the `present` tool.
27
- - `renderSpec(spec, catalog)` — render a validated spec yourself.
53
+ ```json
54
+ {"root":{"component":"Card","props":{"title":"Hello"}}}
55
+ ```
28
56
 
29
- ## Interactive surfaces
57
+ A single tool avoids one tool per visual component; its schema size still depends on the catalog. Use a focused catalog and inspect the result in devtools.
30
58
 
31
- Nodes can do more than display props:
59
+ ## Bind data and handle events
32
60
 
33
- ```jsonc
34
- {
35
- "component": "RecordTable", "id": "orders",
36
- "bind": { "rows": "data.paid" }, // read app data
37
- "on": { "select": [{ "type": "set", "path": "ui.selected", "value": "{{value}}" }] }
38
- }
39
- {
40
- "component": "Button", "when": "ui.selected", "props": { "label": "Refund this order" },
41
- "on": { "press": [{ "type": "tool", "name": "refund_order", "args": { "id": "{{ui.selected.id}}" }, "into": "ui.last" }] }
42
- }
61
+ - `$data.*` in Lang (or `data.*` in JSON bindings) reads application data passed through `data`. Generated UI cannot write to it. Read paths can filter and count arrays: `$data.items[done=true].length`.
62
+ - `$ui.*` is local interface state. Actions can set, merge, push, remove, increment, or toggle it.
63
+ - A catalog entry maps event names to callback props, for example `events: { press: "onPress" }`.
64
+ - `when` conditionally shows a node. `@Say` forwards a message to `onMessage`.
65
+
66
+ For example:
67
+
68
+ ```text
69
+ root = Stack([input, greeting])
70
+ input = TextInput("Name", value: $ui.name, change: @Set($ui.name, $event))
71
+ greeting = Text("Thanks for sharing your name.", when: $ui.name)
43
72
  ```
44
73
 
45
- - `data.*` is what you pass as `<AgentSurface data={...}>`. It is read-only to generated UI and re-renders when it changes.
46
- - `ui.*` belongs to the surface. Actions (`set`, `merge`, `push`, `remove`, `increment`, `toggle`) may only write here.
47
- - `tool` actions call registered tools through `agentable.invokeCapability`, so confirmation, caching and invalidation work exactly as for an agent. Only tools an agent could reach (tier 0 or 1) are callable; narrow them with `tools={[...]}` or turn them off with `tools={false}`.
48
- - `present({ ops })` patches the current tree by node id (`add`, `update`, `remove`, `set`) instead of resending it.
49
- - With `data` set, a read-only `<name>_state` tool returns the shape of `data`, the current `ui` state and the tree.
50
- - Catalog entries declare events as `events: { press: "onPress" }`: event name to callback prop, whose first argument becomes `{{value}}`.
74
+ Tool routing differs by host:
75
+
76
+ | Host | Tool configuration |
77
+ |---|---|
78
+ | `Renderer` | `tools={{ tool_name: callback }}` supplies callbacks. Missing names can fall back to a mounted Agentable registry. Direct callbacks follow your application's policies. |
79
+ | `AgentSurface` | `tools={["tool_name"]}` narrows callable registry tools; `tools={false}` disables tool actions. Registry calls retain confirmation, caching, and invalidation. |
51
80
 
52
- ## Catalog entries
81
+ `AgentSurface` can also patch the current tree with `present({ ops })`, using node ids and `add`, `update`, `remove`, or `set` operations. When `data` is supplied, a read-only `<name>_state` tool reports the data shape, UI state, and current tree; set `stateTool={false}` to omit it.
82
+
83
+ ## Validation and custom catalogs
84
+
85
+ ```tsx
86
+ import { Stat } from "@agentable-ui/ui";
53
87
 
54
- ```ts
55
88
  const catalog = {
56
89
  Stat: {
57
90
  component: Stat,
58
- props: z.object({ label: z.string(), value: z.string(), delta: z.string().optional() }),
59
- description: "A single headline number.",
91
+ description: "A headline number.",
92
+ props: {
93
+ type: "object",
94
+ properties: {
95
+ label: { type: "string" },
96
+ value: { type: "string" },
97
+ },
98
+ required: ["label", "value"],
99
+ },
60
100
  },
61
101
  };
62
102
  ```
63
103
 
64
- `props` accepts a JSON Schema object or any Standard Schema validator. `uiCatalog` from [`@agentable-ui/ui`](../ui) is a ready-made set.
104
+ Catalog schemas accept JSON Schema or supported Standard Schema validators. Unknown components, invalid props, and excessive depth/node counts produce structured issues. `AgentSurface` defaults to depth 6 and 60 nodes. Renderer prunes invalid nodes so valid parts can remain visible; validation options depend on the host.
105
+
106
+ Lower-level exports include `validateSpec`, `catalogSchema`, and `renderSpec`. Validate before calling `renderSpec` directly.
107
+
108
+ See [lang](../lang) for syntax, prompts, and parsing; [ui](../ui) for the built-in catalog.
65
109
 
66
- MIT
110
+ MIT licensed.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ComponentType, ReactNode } from "react";
2
2
  import { JSONSchema, SchemaInput, Tier } from "@agentable-ui/core";
3
+ import { Library } from "@agentable-ui/lang";
3
4
  //#region src/state.d.ts
4
5
  /**
5
6
  * Surface state. Two roots: `data` is the app's, read-only to generated UI;
@@ -9,9 +10,19 @@ interface SurfaceState {
9
10
  data: Record<string, unknown>;
10
11
  ui: Record<string, unknown>;
11
12
  }
12
- /** "a.b[0].c" -> ["a", "b", 0, "c"]. Throws on __proto__, prototype and constructor segments. */
13
- export declare function parsePath(path: string): Array<string | number>;
14
- /** Read a path, following own properties only. Undefined for missing or unsafe paths. */
13
+ /** A read-only list filter in a path: `items[done=true]`, `items[status!="archived"]`. */
14
+ interface PathFilter {
15
+ field: string;
16
+ op: "=" | "!=";
17
+ value: string | number | boolean;
18
+ }
19
+ type PathKey = string | number | PathFilter;
20
+ /**
21
+ * "a.b[0].c" -> ["a", "b", 0, "c"]; "items[done=true]" -> ["items", {field: "done", op: "=", value: true}].
22
+ * Throws on __proto__, prototype and constructor segments, and on malformed selectors.
23
+ */
24
+ export declare function parsePath(path: string): PathKey[];
25
+ /** Read a path, following own properties only. Filters narrow arrays. Undefined for missing or unsafe paths. */
15
26
  export declare function getPath(obj: unknown, path: string): unknown;
16
27
  type StateAction = {
17
28
  type: "set";
@@ -61,7 +72,12 @@ interface ToolAction {
61
72
  args?: Record<string, unknown>;
62
73
  into?: string;
63
74
  }
64
- type UIAction = StateAction | ToolAction;
75
+ /** Send text to the assistant (the surface's `onMessage`). */
76
+ interface MessageAction {
77
+ type: "message";
78
+ text: string;
79
+ }
80
+ type UIAction = StateAction | ToolAction | MessageAction;
65
81
  interface UISpec {
66
82
  component: string;
67
83
  /** Stable id, needed to target the node with patch ops. */
@@ -113,6 +129,11 @@ interface ValidateOptions {
113
129
  state?: SurfaceState;
114
130
  /** Tool names that `tool` actions may call. None when absent. */
115
131
  tools?: ReadonlySet<string>;
132
+ /**
133
+ * Drop nodes with issues instead of failing the whole spec. Used while a
134
+ * program streams in, where a half-formed node must not blank the rest.
135
+ */
136
+ prune?: boolean;
116
137
  }
117
138
  export declare function isVisible(node: Pick<UISpec, "when">, state: SurfaceState): boolean;
118
139
  /** Static props overlaid with bound values that currently resolve. */
@@ -127,6 +148,7 @@ export declare function validateSpec(spec: UISpec, catalog: Record<string, Valid
127
148
  ok: true;
128
149
  spec: UISpec;
129
150
  nodes: number;
151
+ issues: SpecIssue[];
130
152
  } | {
131
153
  ok: false;
132
154
  issues: SpecIssue[];
@@ -141,6 +163,46 @@ export declare function applyOps(root: UISpec, ops: SurfaceOp[]): {
141
163
  state: StateAction[];
142
164
  };
143
165
  //#endregion
166
+ //#region src/renderer.d.ts
167
+ interface RendererError {
168
+ code: string;
169
+ message: string;
170
+ line?: number;
171
+ path?: string;
172
+ }
173
+ interface RendererProps {
174
+ /** Components the program may use. The library (signatures, prompt) is derived from it. */
175
+ components: Catalog;
176
+ /** Pass a prebuilt library to share it with prompt generation. */
177
+ library?: Library;
178
+ /** The program text received so far. */
179
+ response: string;
180
+ /** True while tokens are still arriving: partial lines are held back and the UI is inert. */
181
+ isStreaming?: boolean;
182
+ /** App data the program may bind to as $data.*. */
183
+ data?: Record<string, unknown>;
184
+ /** Functions @Tool may call by name. Names not here fall back to the Agentable registry when a provider is mounted. */
185
+ tools?: Record<string, (args: Record<string, unknown>) => unknown | Promise<unknown>>;
186
+ /** Text sent by @Say. */
187
+ onMessage?: (text: string, node: UISpec) => void;
188
+ /** Structured parse and validation errors; `[]` once clean. Warnings are left out. */
189
+ onError?: (errors: RendererError[]) => void;
190
+ /** An action failed or was declined. */
191
+ onActionError?: (message: string, node: UISpec) => void;
192
+ /** $ui.* state to start from. */
193
+ initialUi?: Record<string, unknown>;
194
+ onUiChange?: (ui: Record<string, unknown>) => void;
195
+ placeholder?: ReactNode;
196
+ className?: string;
197
+ }
198
+ /**
199
+ * Renders an Agentable Lang program as it streams. Each complete line is
200
+ * parsed, the tree is validated against the catalog with bad nodes pruned,
201
+ * and the rest draws immediately. Bindings, `when` and actions work exactly
202
+ * as in <AgentSurface>; tool actions go to `tools` or the registry.
203
+ */
204
+ export declare function Renderer({ components, library, response, isStreaming, data, tools, onMessage, onError, onActionError, initialUi, onUiChange, placeholder, className }: RendererProps): import("react").JSX.Element;
205
+ //#endregion
144
206
  //#region src/index.d.ts
145
207
  export interface CatalogComponent<P = Record<string, unknown>> {
146
208
  component: ComponentType<P>;
@@ -161,6 +223,8 @@ export type Catalog = Record<string, CatalogComponent<never>>;
161
223
  export interface PresentArgs {
162
224
  /** The tree to render. Replaces the current one and resets ui state. null clears the surface. */
163
225
  root?: UISpec | null;
226
+ /** The tree as an Agentable Lang program (about half the tokens of `root`). */
227
+ program?: string;
164
228
  /** Patch the current tree (applied after `root` when both are given). */
165
229
  ops?: SurfaceOp[];
166
230
  /** ui.* state to start from when sending `root`. */
@@ -205,6 +269,8 @@ export interface AgentSurfaceProps {
205
269
  stateTool?: string | false;
206
270
  /** Called when an action in the generated UI fails or is declined. */
207
271
  onActionError?: (message: string, node: UISpec) => void;
272
+ /** Receives text a `@Say` / message action sends to the assistant. Without it the action fails. */
273
+ onMessage?: (text: string, node: UISpec) => void;
208
274
  }
209
275
  /**
210
276
  * Renders whatever the agent last called `present` with. Every catalog
@@ -216,6 +282,6 @@ export interface AgentSurfaceProps {
216
282
  * generated UI and an agent reach a tool through the same registry path, so
217
283
  * confirmation, caching and invalidation apply to both.
218
284
  */
219
- export declare function AgentSurface({ components, name, id, group, tier, priority, maxDepth, maxNodes, placeholder, onPresent, className, clearable, data, tools, stateTool, onActionError }: AgentSurfaceProps): import("react").JSX.Element;
285
+ export declare function AgentSurface({ components, name, id, group, tier, priority, maxDepth, maxNodes, placeholder, onPresent, className, clearable, data, tools, stateTool, onActionError, onMessage }: AgentSurfaceProps): import("react").JSX.Element;
220
286
  //#endregion
221
- export type { SpecIssue, StateAction, SurfaceOp, SurfaceState, ToolAction, UIAction, UISpec, UiStore, ValidateOptions };
287
+ export type { MessageAction, PathFilter, PathKey, RendererError, RendererProps, SpecIssue, StateAction, SurfaceOp, SurfaceState, ToolAction, UIAction, UISpec, UiStore, ValidateOptions };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { Component, createElement, useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
1
+ import { Component, createElement, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { toJsonSchema, validateInput } from "@agentable-ui/core";
3
3
  import { useAgentable, useAgentableSnapshot, useCapability } from "@agentable-ui/react";
4
+ import { createLibrary } from "@agentable-ui/lang";
4
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
6
  //#region src/state.ts
6
7
  /** Keys that reach an object's prototype. Paths come from agents and user input, so they are never followed. */
@@ -9,14 +10,62 @@ const UNSAFE_KEYS = /* @__PURE__ */ new Set([
9
10
  "prototype",
10
11
  "constructor"
11
12
  ]);
12
- /** "a.b[0].c" -> ["a", "b", 0, "c"]. Throws on __proto__, prototype and constructor segments. */
13
+ const isFilter = (k) => typeof k === "object";
14
+ function literal(raw) {
15
+ if (raw.startsWith("\"")) return raw.slice(1, -1);
16
+ if (raw === "true") return true;
17
+ if (raw === "false") return false;
18
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
19
+ return raw;
20
+ }
21
+ /**
22
+ * "a.b[0].c" -> ["a", "b", 0, "c"]; "items[done=true]" -> ["items", {field: "done", op: "=", value: true}].
23
+ * Throws on __proto__, prototype and constructor segments, and on malformed selectors.
24
+ */
13
25
  function parsePath(path) {
14
26
  if (!path) return [];
15
- const keys = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean).map((p) => /^\d+$/.test(p) ? Number(p) : p);
16
- for (const k of keys) if (typeof k === "string" && UNSAFE_KEYS.has(k)) throw new Error(`Path "${path}" uses the reserved key "${k}".`);
27
+ const keys = [];
28
+ let i = 0;
29
+ while (i < path.length) {
30
+ const c = path[i];
31
+ if (c === ".") {
32
+ i++;
33
+ continue;
34
+ }
35
+ const rest = path.slice(i);
36
+ if (c === "[") {
37
+ const index = /^\[(\d+)\]/.exec(rest);
38
+ if (index) {
39
+ keys.push(Number(index[1]));
40
+ i += index[0].length;
41
+ continue;
42
+ }
43
+ const filter = /^\[([A-Za-z_][A-Za-z0-9_]*)(!?=)("[^"\]]*"|[A-Za-z0-9_.-]+)\]/.exec(rest);
44
+ if (!filter) throw new Error(`Path "${path}" has a bad selector; use [0] or [field=value].`);
45
+ keys.push({
46
+ field: filter[1],
47
+ op: filter[2],
48
+ value: literal(filter[3])
49
+ });
50
+ i += filter[0].length;
51
+ continue;
52
+ }
53
+ const seg = /^[^.[\]]+/.exec(rest)[0];
54
+ keys.push(/^\d+$/.test(seg) ? Number(seg) : seg);
55
+ i += seg.length;
56
+ }
57
+ for (const k of keys) {
58
+ const name = isFilter(k) ? k.field : k;
59
+ if (typeof name === "string" && UNSAFE_KEYS.has(name)) throw new Error(`Path "${path}" uses the reserved key "${name}".`);
60
+ }
17
61
  return keys;
18
62
  }
19
- /** Read a path, following own properties only. Undefined for missing or unsafe paths. */
63
+ function matches(item, f) {
64
+ const v = item !== null && typeof item === "object" && Object.hasOwn(item, f.field) ? item[f.field] : void 0;
65
+ const eq = typeof f.value === "boolean" ? Boolean(v) === f.value : v !== void 0 && v !== null && String(v) === String(f.value);
66
+ return f.op === "=" ? eq : !eq;
67
+ }
68
+ /** Read a path, following own properties only. Filters narrow arrays. Undefined for missing or unsafe paths. */
20
69
  function getPath(obj, path) {
21
70
  let keys;
22
71
  try {
@@ -26,6 +75,11 @@ function getPath(obj, path) {
26
75
  }
27
76
  let cur = obj;
28
77
  for (const key of keys) {
78
+ if (isFilter(key)) {
79
+ if (!Array.isArray(cur)) return void 0;
80
+ cur = cur.filter((item) => matches(item, key));
81
+ continue;
82
+ }
29
83
  if (cur == null || typeof cur !== "object" || !Object.hasOwn(cur, key)) return void 0;
30
84
  cur = cur[key];
31
85
  }
@@ -63,6 +117,7 @@ function setPath(obj, keys, value) {
63
117
  function uiKeys(path) {
64
118
  const keys = parsePath(path);
65
119
  if (keys[0] !== "ui" || keys.length < 2) throw new Error(`Actions may only write ui.* paths (got "${path}").`);
120
+ if (keys.some(isFilter)) throw new Error(`Filters like [field=value] are read-only (got "${path}").`);
66
121
  return keys.slice(1);
67
122
  }
68
123
  function reduce(ui, a) {
@@ -192,9 +247,13 @@ function actionIssues(a, path, tools) {
192
247
  });
193
248
  return out;
194
249
  }
250
+ if (a.type === "message") return typeof a.text === "string" ? [] : [{
251
+ path: `${path}.text`,
252
+ message: "message needs a text string."
253
+ }];
195
254
  if (!STATE_ACTIONS.has(a.type)) return [{
196
255
  path: `${path}.type`,
197
- message: `Unknown action "${a.type}". Use set, merge, push, remove, increment, toggle or tool.`
256
+ message: `Unknown action "${a.type}". Use set, merge, push, remove, increment, toggle, tool or message.`
198
257
  }];
199
258
  if (!isStatePath(a.path, ["ui"])) return [{
200
259
  path: `${path}.path`,
@@ -364,6 +423,7 @@ async function validateSpec(spec, catalog, allowed, opts = {}) {
364
423
  if (Object.keys(on).length) out.on = on;
365
424
  }
366
425
  }
426
+ const ownBad = issues.length > before;
367
427
  if (Array.isArray(node.children) && node.children.length) {
368
428
  if (!entry.childrenProp) issues.push({
369
429
  path: `${path}.children`,
@@ -378,17 +438,22 @@ async function validateSpec(spec, catalog, allowed, opts = {}) {
378
438
  out.children = kids;
379
439
  }
380
440
  }
381
- return issues.length > before ? null : out;
441
+ if (ownBad) {
442
+ nodes--;
443
+ return null;
444
+ }
445
+ return out;
382
446
  }
383
447
  const cleaned = await walk(spec, "root", 1, false);
384
- if (issues.length || !cleaned) return {
448
+ if (!cleaned || issues.length && !opts.prune) return {
385
449
  ok: false,
386
450
  issues
387
451
  };
388
452
  return {
389
453
  ok: true,
390
454
  spec: cleaned,
391
- nodes
455
+ nodes,
456
+ issues
392
457
  };
393
458
  }
394
459
  /**
@@ -465,6 +530,185 @@ function replaceIn(node, target, next) {
465
530
  return kids.some((c) => replaceIn(c, target, next));
466
531
  }
467
532
  //#endregion
533
+ //#region src/renderer.tsx
534
+ /**
535
+ * Renders an Agentable Lang program as it streams. Each complete line is
536
+ * parsed, the tree is validated against the catalog with bad nodes pruned,
537
+ * and the rest draws immediately. Bindings, `when` and actions work exactly
538
+ * as in <AgentSurface>; tool actions go to `tools` or the registry.
539
+ */
540
+ function Renderer({ components, library, response, isStreaming = false, data, tools, onMessage, onError, onActionError, initialUi, onUiChange, placeholder, className }) {
541
+ const lib = useMemo(() => library ?? createLibrary(components), [library, components]);
542
+ const [store] = useState(() => createUiStore(initialUi ?? {}));
543
+ const ui = useSyncExternalStore(store.subscribe, store.get, store.get);
544
+ const agentable = useAgentable();
545
+ const snapshot = useAgentableSnapshot();
546
+ const [spec, setSpec] = useState(null);
547
+ const [actionError, setActionError] = useState(null);
548
+ const dataRef = useRef(data ?? {});
549
+ dataRef.current = data ?? {};
550
+ const toolsRef = useRef(tools);
551
+ toolsRef.current = tools;
552
+ const onMessageRef = useRef(onMessage);
553
+ onMessageRef.current = onMessage;
554
+ const onErrorRef = useRef(onError);
555
+ onErrorRef.current = onError;
556
+ const onActionErrorRef = useRef(onActionError);
557
+ onActionErrorRef.current = onActionError;
558
+ const onUiChangeRef = useRef(onUiChange);
559
+ onUiChangeRef.current = onUiChange;
560
+ useEffect(() => store.subscribe(() => onUiChangeRef.current?.(store.get())), [store]);
561
+ const registryTools = useMemo(() => {
562
+ const out = /* @__PURE__ */ new Map();
563
+ for (const c of snapshot.capabilities) if (c.toolName && c.declaredTier <= 1 && !out.has(c.toolName)) out.set(c.toolName, c.id);
564
+ return out;
565
+ }, [snapshot.capabilities]);
566
+ const registryRef = useRef(registryTools);
567
+ registryRef.current = registryTools;
568
+ const callable = useMemo(() => /* @__PURE__ */ new Set([...Object.keys(tools ?? {}), ...registryTools.keys()]), [tools, registryTools]);
569
+ const parsed = useMemo(() => lib.parse(isStreaming ? heldBack(response) : response), [
570
+ lib,
571
+ response,
572
+ isStreaming
573
+ ]);
574
+ useEffect(() => {
575
+ let live = true;
576
+ const allowed = new Set(Object.keys(components));
577
+ const errors = parsed.errors.filter((e) => e.severity === "error" && (e.code !== "no-root" || !isStreaming)).map(fromLang);
578
+ if (!parsed.root) {
579
+ setSpec(null);
580
+ onErrorRef.current?.(errors);
581
+ return;
582
+ }
583
+ validateSpec(parsed.root, components, allowed, {
584
+ state: {
585
+ data: dataRef.current,
586
+ ui: store.get()
587
+ },
588
+ tools: callable,
589
+ prune: true,
590
+ maxNodes: 200
591
+ }).then((r) => {
592
+ if (!live) return;
593
+ if (r.ok) {
594
+ setSpec(r.spec);
595
+ onErrorRef.current?.([...errors, ...r.issues.map(fromIssue)]);
596
+ } else {
597
+ setSpec(null);
598
+ onErrorRef.current?.([...errors, ...r.issues.map(fromIssue)]);
599
+ }
600
+ });
601
+ return () => {
602
+ live = false;
603
+ };
604
+ }, [
605
+ parsed,
606
+ components,
607
+ callable,
608
+ isStreaming
609
+ ]);
610
+ const fail = useCallback((message, node) => {
611
+ if (onActionErrorRef.current) onActionErrorRef.current(message, node);
612
+ else setActionError(message);
613
+ }, []);
614
+ const emit = useCallback((node, event, value) => {
615
+ const actions = node.on?.[event];
616
+ if (!actions?.length) return;
617
+ (async () => {
618
+ setActionError(null);
619
+ for (const raw of actions) {
620
+ const action = interpolate(raw, { value }, {
621
+ data: dataRef.current,
622
+ ui: store.get()
623
+ });
624
+ if (action.type === "message") {
625
+ if (!onMessageRef.current) return fail("Nothing is listening for messages here.", node);
626
+ onMessageRef.current(action.text, node);
627
+ continue;
628
+ }
629
+ if (action.type !== "tool") {
630
+ try {
631
+ store.dispatch(action);
632
+ } catch (e) {
633
+ return fail(e instanceof Error ? e.message : String(e), node);
634
+ }
635
+ continue;
636
+ }
637
+ let result;
638
+ const fn = toolsRef.current?.[action.name];
639
+ try {
640
+ if (fn) result = await fn(action.args ?? {});
641
+ else {
642
+ const capId = registryRef.current.get(action.name);
643
+ if (!agentable || !capId) return fail(`Tool "${action.name}" is not available.`, node);
644
+ result = parseResult$1(await agentable.invokeCapability(capId, action.args ?? {}));
645
+ }
646
+ } catch (e) {
647
+ return fail(e instanceof Error ? e.message : String(e), node);
648
+ }
649
+ if (result && typeof result === "object" && "error" in result) return fail(String(result.error), node);
650
+ if (action.into) store.dispatch({
651
+ type: "set",
652
+ path: action.into,
653
+ value: result
654
+ });
655
+ }
656
+ })();
657
+ }, [
658
+ agentable,
659
+ store,
660
+ fail
661
+ ]);
662
+ const ctx = useMemo(() => ({
663
+ state: {
664
+ data: data ?? {},
665
+ ui
666
+ },
667
+ emit
668
+ }), [
669
+ data,
670
+ ui,
671
+ emit
672
+ ]);
673
+ return /* @__PURE__ */ jsxs("div", {
674
+ className,
675
+ "data-agentable-surface": "",
676
+ "data-streaming": isStreaming ? "" : void 0,
677
+ "data-empty": spec ? void 0 : "",
678
+ children: [actionError ? /* @__PURE__ */ jsx("p", {
679
+ role: "alert",
680
+ "data-agentable-surface-error": "",
681
+ children: actionError
682
+ }) : null, spec ? /* @__PURE__ */ jsx("div", {
683
+ "data-agentable-surface-body": "",
684
+ inert: isStreaming || void 0,
685
+ children: renderSpec(spec, components, "0", ctx)
686
+ }) : placeholder ?? null]
687
+ });
688
+ }
689
+ /** While streaming, the last unterminated line is not parsed. */
690
+ function heldBack(text) {
691
+ const nl = text.lastIndexOf("\n");
692
+ return nl < 0 ? "" : text.slice(0, nl + 1);
693
+ }
694
+ const fromLang = (e) => ({
695
+ code: e.code,
696
+ message: e.message,
697
+ line: e.line
698
+ });
699
+ const fromIssue = (i) => ({
700
+ code: "invalid-node",
701
+ message: i.message,
702
+ path: i.path
703
+ });
704
+ function parseResult$1(text) {
705
+ try {
706
+ return JSON.parse(text);
707
+ } catch {
708
+ return text;
709
+ }
710
+ }
711
+ //#endregion
468
712
  //#region src/index.tsx
469
713
  const ACTION_TYPES = [
470
714
  "set",
@@ -583,6 +827,10 @@ function catalogSchema(catalog, allowed) {
583
827
  anyOf: [{ $ref: "#/$defs/node" }, { type: "null" }],
584
828
  description: "Tree to render; replaces the current one. null clears."
585
829
  },
830
+ program: {
831
+ type: "string",
832
+ description: "The tree as a program, one node per line: `root = Stack([a, b])`, `a = Stat(\"Revenue\", \"$48k\")`. Same components; fewer tokens than root."
833
+ },
586
834
  ops: {
587
835
  type: "array",
588
836
  items: { $ref: "#/$defs/op" },
@@ -630,7 +878,7 @@ function renderSpec(spec, catalog, key = "0", ctx) {
630
878
  * generated UI and an agent reach a tool through the same registry path, so
631
879
  * confirmation, caching and invalidation apply to both.
632
880
  */
633
- function AgentSurface({ components, name = "present", id, group, tier, priority, maxDepth = 6, maxNodes = 60, placeholder, onPresent, className, clearable = true, data, tools, stateTool, onActionError }) {
881
+ function AgentSurface({ components, name = "present", id, group, tier, priority, maxDepth = 6, maxNodes = 60, placeholder, onPresent, className, clearable = true, data, tools, stateTool, onActionError, onMessage }) {
634
882
  const [state, setState] = useState({
635
883
  spec: null,
636
884
  at: 0
@@ -644,8 +892,13 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
644
892
  onPresentRef.current = onPresent;
645
893
  const onActionErrorRef = useRef(onActionError);
646
894
  onActionErrorRef.current = onActionError;
895
+ const onMessageRef = useRef(onMessage);
896
+ onMessageRef.current = onMessage;
647
897
  const catalogRef = useRef(components);
648
898
  catalogRef.current = components;
899
+ const library = useMemo(() => createLibrary(components), [components]);
900
+ const libraryRef = useRef(library);
901
+ libraryRef.current = library;
649
902
  const specRef = useRef(state.spec);
650
903
  specRef.current = state.spec;
651
904
  const dataRef = useRef(data ?? {});
@@ -704,7 +957,7 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
704
957
  const events = Object.keys(e.events ?? {});
705
958
  return `${n}{${propSummary(schema)}}${e.childrenProp ? "+children" : ""}${events.length ? ` on:${events.join("|")}` : ""}`;
706
959
  });
707
- let text = "Render UI with this app's components: a tree of {component, props, children}. Nodes can bind props to state, run actions on events, and be patched by id via ops.";
960
+ let text = "Render UI with this app's components: a program (one `name = Component(args)` line per node) or a {component, props, children} tree. Nodes bind props to state, run actions on events, and can be patched by id via ops.";
708
961
  if (readName) text += ` Call ${readName} for bindable data.`;
709
962
  text += ` Components: ${lines.join(" ")}`;
710
963
  if (text.length > 500) text = text.slice(0, 497) + "...";
@@ -729,10 +982,23 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
729
982
  onPresentRef.current?.(null);
730
983
  return { cleared: true };
731
984
  }
732
- if (args.root === void 0 && !args.ops?.length) return { error: "Send root to render a tree, ops to patch it, or root: null to clear." };
733
- let tree = args.root ?? specRef.current;
985
+ let root = args.root;
986
+ if (typeof args.program === "string" && root === void 0) {
987
+ const parsed = libraryRef.current.parse(args.program);
988
+ const errors = parsed.errors.filter((e) => e.severity === "error");
989
+ if (!parsed.root || errors.length) return {
990
+ error: "Invalid program.",
991
+ issues: parsed.errors.map((e) => ({
992
+ path: `line ${e.line}`,
993
+ message: e.message
994
+ }))
995
+ };
996
+ root = parsed.root;
997
+ }
998
+ if (root === void 0 && !args.ops?.length) return { error: "Send program or root to render, ops to patch, or root: null to clear." };
999
+ let tree = root ?? specRef.current;
734
1000
  if (!tree) return { error: "Nothing is presented yet; send root first." };
735
- const draft = createUiStore(args.root ? args.ui ?? {} : store.get());
1001
+ const draft = createUiStore(root ? args.ui ?? {} : store.get());
736
1002
  try {
737
1003
  if (args.ops?.length) {
738
1004
  const patched = applyOps(tree, args.ops);
@@ -755,13 +1021,13 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
755
1021
  error: "Invalid UI spec.",
756
1022
  issues: result.issues
757
1023
  };
758
- const title = args.title ?? (args.root ? void 0 : state.title);
1024
+ const title = args.title ?? (root ? void 0 : state.title);
759
1025
  store.replace(draft.get());
760
1026
  setError(null);
761
1027
  setState({
762
1028
  spec: result.spec,
763
1029
  title,
764
- at: args.root ? Date.now() : state.at
1030
+ at: root ? Date.now() : state.at
765
1031
  });
766
1032
  onPresentRef.current?.(result.spec, title);
767
1033
  return {
@@ -827,8 +1093,8 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
827
1093
  }
828
1094
  } : null);
829
1095
  const fail = useCallback((message, node) => {
830
- setError(message);
831
- onActionErrorRef.current?.(message, node);
1096
+ if (onActionErrorRef.current) onActionErrorRef.current(message, node);
1097
+ else setError(message);
832
1098
  }, []);
833
1099
  const emit = useCallback((node, event, value) => {
834
1100
  const actions = node.on?.[event];
@@ -840,6 +1106,11 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
840
1106
  data: dataRef.current,
841
1107
  ui: store.get()
842
1108
  });
1109
+ if (action.type === "message") {
1110
+ if (!onMessageRef.current) return fail("Nothing is listening for messages here.", node);
1111
+ onMessageRef.current(action.text, node);
1112
+ continue;
1113
+ }
843
1114
  if (action.type !== "tool") {
844
1115
  try {
845
1116
  store.dispatch(action);
@@ -944,4 +1215,4 @@ function shapeOf(value, depth = 0) {
944
1215
  return typeof value;
945
1216
  }
946
1217
  //#endregion
947
- export { AgentSurface, applyOps, catalogSchema, createUiStore, getPath, interpolate, isVisible, parsePath, renderSpec, resolveProps, validateSpec };
1218
+ export { AgentSurface, Renderer, applyOps, catalogSchema, createUiStore, getPath, interpolate, isVisible, parsePath, renderSpec, resolveProps, validateSpec };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agentable-ui/genui",
3
- "version": "0.1.0",
4
- "description": "Generative UI for Agentable: AgentSurface, catalog schema generation, validated rendering via a single present tool.",
3
+ "version": "0.3.1",
4
+ "description": "Generative UI for Agentable: a streaming Renderer for Agentable Lang programs, AgentSurface with a single validated present tool, catalog schema generation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -34,8 +34,9 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@agentable-ui/core": "^0.1.0",
38
- "@agentable-ui/react": "^0.1.0"
37
+ "@agentable-ui/react": "^0.1.1",
38
+ "@agentable-ui/lang": "^0.2.1",
39
+ "@agentable-ui/core": "^0.2.0"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "react": "^18.2.0 || ^19.0.0"
@@ -46,7 +47,7 @@
46
47
  "@types/react": "^19.2.18",
47
48
  "@types/react-dom": "^19.2.7",
48
49
  "@testing-library/react": "^16.3.3",
49
- "@agentable-ui/ui": "^0.1.0"
50
+ "@agentable-ui/ui": "^0.2.1"
50
51
  },
51
52
  "scripts": {
52
53
  "build": "tsdown",