@pouchy_ai/companion-sdk 0.40.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/a2ui.ts ADDED
@@ -0,0 +1,881 @@
1
+ // Instant UI ⇄ Google A2UI v0.9 adapter — the PUBLIC, package-shipped home of
2
+ // the projection (round #127; owner-approved 2026-07-25). Integrators import
3
+ // `toA2UI` / `fromA2UI` from `@pouchy_ai/companion-sdk` (or the CDN bundle) and
4
+ // put the adapter in front of any renderer built against the A2UI catalog —
5
+ // no more vendoring the repo source, which was the one DX gap of the A2UI
6
+ // alignment.
7
+ //
8
+ // WHY the projection exists: Pouchy's Instant UI is a NESTED, state-bag schema;
9
+ // Google's A2UI v0.9 (the standard AGenUI implements) is a FLAT component list
10
+ // with ID references, a separate dataModel, and JSON-Pointer bindings. Three
11
+ // structural transforms:
12
+ // (i) nested tree → flat components map + ID refs
13
+ // (ii) state bag + bind/template → dataModel + {path:"/key"} JSON Pointer
14
+ // (iii) Button's embedded action → A2UI event {name, context}, the action
15
+ // carried in context so the host can dispatch it locally (no LLM
16
+ // round-trip) — external A2UI agents just receive the event.
17
+ //
18
+ // Fidelity: concepts A2UI genuinely has no standard for (Progress, Accordion,
19
+ // Chart's area/pie kinds, computed, `when`, Text tone, Slider step, TextField
20
+ // placeholder/maxLength, Select placeholder) ride a per-component `_pouchy`
21
+ // extension (A2UI renderers ignore unknown keys; fromA2UI reconstructs from
22
+ // it). Input widgets otherwise emit their STANDARD A2UI fields — TextField
23
+ // `variant` (longText|shortText), ChoicePicker `variant` (mutuallyExclusive),
24
+ // and a single-placeholder Text template emits a real {path} binding — so an
25
+ // external renderer honours the widget kind, not just a default.
26
+ // `fromA2UI(toA2UI(x))` is a faithful round-trip (pinned by the app-side
27
+ // adapter tests + the admin A2UI conformance probe).
28
+ //
29
+ // Spec conformance: component + field names below are verified against
30
+ // AGenUI's agenui_catalog.json and google/A2UI v0.9 — Text/Button/Row/Column/
31
+ // Card/Tabs/Image(url,description)/Video(url)/AudioPlayer(url)/Slider/
32
+ // CheckBox/ChoicePicker/TextField/DateTimeInput/Divider(axis) are standard;
33
+ // Table(columns,rows), Chart(chartType,data) and Markdown(content) are also
34
+ // standard catalog entries, so they emit the standard shape while `_pouchy`
35
+ // carries Pouchy's richer original for a lossless round-trip. Only Progress +
36
+ // Accordion stay `pouchy/`-namespaced custom components. Centralized in the
37
+ // A2UI map so a spec change is a one-line edit, never a transform rewrite.
38
+ //
39
+ // TYPES: the panel types below are a STRUCTURAL MIRROR of the app's canonical
40
+ // genui schema (src/lib/genui/schema.ts) — the package must stay standalone
41
+ // (no app imports; the npm build's rootDir is this directory), so the type
42
+ // text is duplicated ON PURPOSE, with one deliberate loosening: a Button /
43
+ // Table-row action is an opaque `EmbeddedAction { type }` here (the app's full
44
+ // AgentAction union is app-internal; the adapter only carries actions
45
+ // verbatim, never reads their fields). Drift is compile-enforced: the app-side
46
+ // shim (src/lib/genui/a2ui-adapter.ts) passes its STRICT DynamicInterface
47
+ // straight into `toA2UI` with no cast, so a mirror that goes incompatible or
48
+ // loses a node kind fails `pnpm check`; the node-type CATALOG is additionally
49
+ // pinned to the app's GENUI_NODE_SET by a2ui-sdk-drift.test.ts.
50
+
51
+ import type { RenderInterfacePayload } from './protocol';
52
+
53
+ // ─── Instant UI panel types (mirror — see header) ────────────────────────────
54
+
55
+ export type StateValue = string | number | boolean;
56
+
57
+ /** A prop value that is either a literal or a live reference to a state key. */
58
+ export type ValueRef<T extends StateValue> = T | { $state: string };
59
+
60
+ /** An action embedded in a Button / Table row — carried OPAQUELY through the
61
+ * projection (stuffed into the A2UI event context verbatim, restored on the
62
+ * way back). The app narrows it to its full AgentAction union; hosts treat it
63
+ * as data to hand back, never to interpret. */
64
+ export interface EmbeddedAction {
65
+ type: string;
66
+ }
67
+
68
+ export type GenuiTextTone = 'default' | 'muted' | 'strong';
69
+ export type GenuiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
70
+ export type GenuiProgressVariant = 'default' | 'affection' | 'energy' | 'health' | 'tier';
71
+
72
+ export interface GenuiTextNode {
73
+ type: 'Text';
74
+ text?: string;
75
+ /** Template with `{key}` placeholders resolved against interface state. */
76
+ template?: string;
77
+ tone?: GenuiTextTone;
78
+ }
79
+ export interface GenuiButtonNode {
80
+ type: 'Button';
81
+ label: string;
82
+ variant?: GenuiButtonVariant;
83
+ action: EmbeddedAction | null;
84
+ }
85
+ export interface GenuiProgressNode {
86
+ type: 'Progress';
87
+ value: ValueRef<number>;
88
+ max?: number;
89
+ variant?: GenuiProgressVariant;
90
+ }
91
+ export interface GenuiIconNode {
92
+ type: 'Icon';
93
+ name: string;
94
+ size?: number;
95
+ }
96
+ export interface GenuiSliderNode {
97
+ type: 'Slider';
98
+ bind: string;
99
+ min?: number;
100
+ max?: number;
101
+ step?: number;
102
+ label?: string;
103
+ }
104
+ export interface GenuiSwitchNode {
105
+ type: 'Switch';
106
+ bind: string;
107
+ label?: string;
108
+ }
109
+ export interface GenuiSelectOption {
110
+ value: string;
111
+ label: string;
112
+ }
113
+ export interface GenuiSelectNode {
114
+ type: 'Select';
115
+ bind: string;
116
+ options: GenuiSelectOption[];
117
+ placeholder?: string;
118
+ }
119
+ export interface GenuiTextInputNode {
120
+ type: 'TextInput';
121
+ bind: string;
122
+ label?: string;
123
+ placeholder?: string;
124
+ multiline?: boolean;
125
+ maxLength?: number;
126
+ }
127
+ export type GenuiGroupDirection = 'row' | 'column';
128
+ export interface GenuiGroupNode {
129
+ type: 'Group';
130
+ direction: GenuiGroupDirection;
131
+ gap?: number;
132
+ wrap?: boolean;
133
+ children: GenuiNode[];
134
+ }
135
+ export interface GenuiImageNode {
136
+ type: 'Image';
137
+ src: string;
138
+ alt?: string;
139
+ ratio?: number;
140
+ }
141
+ export interface GenuiVideoNode {
142
+ type: 'Video';
143
+ src: string;
144
+ ratio?: number;
145
+ }
146
+ export interface GenuiAudioNode {
147
+ type: 'AudioPlayer';
148
+ src: string;
149
+ title?: string;
150
+ }
151
+ export interface GenuiMarkdownNode {
152
+ type: 'Markdown';
153
+ text: string;
154
+ }
155
+ export type GenuiChartKind = 'bar' | 'line' | 'area' | 'pie' | 'donut';
156
+ export interface GenuiChartDatum {
157
+ label?: string;
158
+ value: number;
159
+ }
160
+ export interface GenuiChartNode {
161
+ type: 'Chart';
162
+ chart: GenuiChartKind;
163
+ data: GenuiChartDatum[];
164
+ max?: number;
165
+ title?: string;
166
+ }
167
+ export interface GenuiTableNode {
168
+ type: 'Table';
169
+ columns: string[];
170
+ rows: string[][];
171
+ title?: string;
172
+ rowActions?: (EmbeddedAction | null)[];
173
+ }
174
+ export interface GenuiDatePickerNode {
175
+ type: 'DatePicker';
176
+ bind: string;
177
+ label?: string;
178
+ min?: string;
179
+ max?: string;
180
+ }
181
+ export interface GenuiWhen {
182
+ key: string;
183
+ equals?: StateValue;
184
+ truthy?: boolean;
185
+ }
186
+ export interface GenuiTab {
187
+ label: string;
188
+ children: GenuiNode[];
189
+ }
190
+ export interface GenuiTabsNode {
191
+ type: 'Tabs';
192
+ tabs: GenuiTab[];
193
+ bind?: string;
194
+ }
195
+ export interface GenuiAccordionSection {
196
+ label: string;
197
+ children: GenuiNode[];
198
+ open?: boolean;
199
+ }
200
+ export interface GenuiAccordionNode {
201
+ type: 'Accordion';
202
+ sections: GenuiAccordionSection[];
203
+ multi?: boolean;
204
+ }
205
+ export interface GenuiDividerNode {
206
+ type: 'Divider';
207
+ }
208
+ export interface GenuiCardNode {
209
+ type: 'Card';
210
+ title?: string;
211
+ children: GenuiNode[];
212
+ }
213
+
214
+ export type GenuiNode = (
215
+ | GenuiTextNode
216
+ | GenuiButtonNode
217
+ | GenuiProgressNode
218
+ | GenuiIconNode
219
+ | GenuiSliderNode
220
+ | GenuiSwitchNode
221
+ | GenuiSelectNode
222
+ | GenuiTextInputNode
223
+ | GenuiGroupNode
224
+ | GenuiImageNode
225
+ | GenuiChartNode
226
+ | GenuiTableNode
227
+ | GenuiDatePickerNode
228
+ | GenuiTabsNode
229
+ | GenuiAccordionNode
230
+ | GenuiDividerNode
231
+ | GenuiCardNode
232
+ | GenuiVideoNode
233
+ | GenuiAudioNode
234
+ | GenuiMarkdownNode
235
+ ) & { when?: GenuiWhen };
236
+
237
+ export interface GenuiComputed {
238
+ op: 'mul' | 'add' | 'sub' | 'div';
239
+ args: (string | number)[];
240
+ round?: number;
241
+ }
242
+
243
+ /** The Instant UI panel — what `companion.ui_action` carries in
244
+ * `payload.interface` (typed strictly here; the event payload declares the
245
+ * loose wire shape). */
246
+ export interface DynamicInterface {
247
+ title?: string;
248
+ nodes: GenuiNode[];
249
+ state?: Record<string, StateValue>;
250
+ computed?: Record<string, GenuiComputed>;
251
+ reportChanges?: boolean;
252
+ allowedActions?: string[];
253
+ }
254
+
255
+ /** What `toA2UI` accepts: the strict panel type, or the loose
256
+ * `payload.interface` exactly as `onRender` delivers it (already validated
257
+ * and clamped server-side, so the structural cast inside is safe). */
258
+ export type InstantUIInput = DynamicInterface | RenderInterfacePayload['interface'];
259
+
260
+ /** The Instant UI node-type catalog, as data — drift-pinned against the app's
261
+ * canonical GENUI_NODE_SET so the mirror can never silently lag a new atom. */
262
+ export const INSTANT_UI_NODE_TYPES = [
263
+ 'Text',
264
+ 'Button',
265
+ 'Progress',
266
+ 'Icon',
267
+ 'Slider',
268
+ 'Switch',
269
+ 'Select',
270
+ 'TextInput',
271
+ 'Group',
272
+ 'Image',
273
+ 'Chart',
274
+ 'Table',
275
+ 'DatePicker',
276
+ 'Tabs',
277
+ 'Accordion',
278
+ 'Divider',
279
+ 'Card',
280
+ 'Video',
281
+ 'AudioPlayer',
282
+ 'Markdown'
283
+ ] as const;
284
+
285
+ // ─── A2UI wire types (the subset we emit/accept) ──────────────────────────────
286
+
287
+ /** A2UI binding: a literal, a JSON-Pointer data binding, or a function call. */
288
+ export type A2UIValue = StateValue | { path: string } | { call: string; args?: unknown };
289
+
290
+ export interface A2UIComponent {
291
+ id: string;
292
+ /** A2UI component type, e.g. "Text" / "Button" / "Row" / "pouchy/Progress". */
293
+ component: string;
294
+ /** Standard A2UI property bag (component-specific; see the mapping per type). */
295
+ [key: string]: unknown;
296
+ /** Pouchy-only reconstruction data for concepts A2UI has no standard for.
297
+ * A2UI renderers ignore it; fromA2UI() uses it to rebuild the exact node. */
298
+ _pouchy?: Record<string, unknown>;
299
+ }
300
+
301
+ /** An A2UI surface = a flat component map + a root id + the data model. The
302
+ * `pouchy` block carries panel-level extensions A2UI has no slot for. */
303
+ export interface A2UISurface {
304
+ root: string;
305
+ components: Record<string, A2UIComponent>;
306
+ dataModel: Record<string, StateValue>;
307
+ pouchy?: {
308
+ title?: string;
309
+ computed?: Record<string, GenuiComputed>;
310
+ reportChanges?: boolean;
311
+ allowedActions?: string[];
312
+ };
313
+ }
314
+
315
+ // Centralized so a spec correction never touches the transform logic below.
316
+ const A2UI = {
317
+ rowComponent: 'Row',
318
+ columnComponent: 'Column',
319
+ // Pouchy-custom components — NOT in the A2UI v0.9 catalog, so they're
320
+ // namespaced and registered as custom components for external interop
321
+ // (they carry semantics A2UI's standard catalog lacks). Progress + Accordion
322
+ // have no standard A2UI equivalent; Table/Chart/Markdown DO (Table+Chart are
323
+ // SDK/Playground catalog entries, Markdown is a Playground example), so those
324
+ // use the standard names below and round-trip losslessly via `_pouchy`.
325
+ progressComponent: 'pouchy/Progress',
326
+ accordionComponent: 'pouchy/Accordion',
327
+ // Standard-catalog component names (verified against AGenUI agenui_catalog.json
328
+ // / google/A2UI v0.9: Table columns/rows, Chart chartType+data, Markdown content).
329
+ chartComponent: 'Chart',
330
+ tableComponent: 'Table',
331
+ textComponent: 'Text',
332
+ buttonComponent: 'Button',
333
+ iconComponent: 'Icon',
334
+ imageComponent: 'Image',
335
+ sliderComponent: 'Slider',
336
+ checkboxComponent: 'CheckBox',
337
+ choicePickerComponent: 'ChoicePicker',
338
+ textFieldComponent: 'TextField',
339
+ dateTimeComponent: 'DateTimeInput',
340
+ tabsComponent: 'Tabs',
341
+ dividerComponent: 'Divider',
342
+ cardComponent: 'Card',
343
+ videoComponent: 'Video',
344
+ audioComponent: 'AudioPlayer',
345
+ markdownComponent: 'Markdown',
346
+ // The event name carrying a Pouchy-embedded action.
347
+ pouchyActionEvent: 'pouchyAction'
348
+ } as const;
349
+
350
+ /** JSON Pointer for a top-level dataModel key (keys are flat, no nesting). */
351
+ const ptr = (key: string): { path: string } => ({ path: `/${key}` });
352
+ const isPtr = (v: unknown): v is { path: string } =>
353
+ !!v && typeof v === 'object' && typeof (v as { path?: unknown }).path === 'string';
354
+ /** Key from a top-level JSON Pointer ("/qty" → "qty"). */
355
+ const ptrKey = (p: { path: string }): string => p.path.replace(/^\//, '');
356
+
357
+ /** If a template is exactly ONE whole-value placeholder ("{total}"), return the
358
+ * key; else null. A2UI Text has no inline string interpolation, but its `text`
359
+ * IS a DynamicString that accepts a {path} binding — so a single-placeholder
360
+ * template can be emitted as a real binding an external renderer interpolates.
361
+ * A mixed template ("¥{total} left") has no A2UI form and stays literal. */
362
+ const singlePlaceholderKey = (tmpl: string): string | null => {
363
+ const m = /^\{([\w.-]+)\}$/.exec(tmpl.trim());
364
+ return m ? m[1] : null;
365
+ };
366
+
367
+ // ─── toA2UI: Instant UI panel → A2UI surface ─────────────────────────────────
368
+
369
+ export function toA2UI(input: InstantUIInput): A2UISurface {
370
+ // The loose `payload.interface` shape is server-validated before it ever
371
+ // reaches a host, so the structural narrow here is safe (see InstantUIInput).
372
+ const iface = input as DynamicInterface;
373
+ const components: Record<string, A2UIComponent> = {};
374
+ let counter = 0;
375
+ const nextId = (): string => `c${counter++}`;
376
+
377
+ /** Emit one component (and its descendants), return its id. */
378
+ const emit = (node: GenuiNode): string => {
379
+ const id = nextId();
380
+ const c = buildComponent(id, node, emit);
381
+ if (node.when) c._pouchy = { ...(c._pouchy ?? {}), when: node.when };
382
+ components[id] = c;
383
+ return id;
384
+ };
385
+
386
+ // The surface root is a Column wrapping the top-level node list (A2UI surfaces
387
+ // have a single root component).
388
+ const childIds = iface.nodes.map(emit);
389
+ const rootId = nextId();
390
+ components[rootId] = {
391
+ id: rootId,
392
+ component: A2UI.columnComponent,
393
+ children: childIds
394
+ };
395
+
396
+ const pouchy: A2UISurface['pouchy'] = {};
397
+ if (iface.title) pouchy.title = iface.title;
398
+ if (iface.computed) pouchy.computed = iface.computed;
399
+ if (iface.reportChanges) pouchy.reportChanges = true;
400
+ if (iface.allowedActions) pouchy.allowedActions = iface.allowedActions;
401
+
402
+ return {
403
+ root: rootId,
404
+ components,
405
+ dataModel: { ...(iface.state ?? {}) },
406
+ ...(Object.keys(pouchy).length > 0 ? { pouchy } : {})
407
+ };
408
+ }
409
+
410
+ /** Encode a Button's embedded action as an A2UI event (host dispatches it
411
+ * locally from context — external agents just receive the event). */
412
+ const encodeAction = (action: EmbeddedAction | null) =>
413
+ action ? { event: { name: A2UI.pouchyActionEvent, context: { action } } } : null;
414
+
415
+ /** Map Pouchy's chart kinds to A2UI's standard `chartType` (line | donut | bar).
416
+ * A2UI has no area/pie, so area→line and pie→donut — the original kind is kept
417
+ * in `_pouchy.chart` so fromA2UI restores it exactly. */
418
+ const A2UI_CHART_TYPE: Record<string, 'line' | 'donut' | 'bar'> = {
419
+ bar: 'bar',
420
+ line: 'line',
421
+ area: 'line',
422
+ pie: 'donut',
423
+ donut: 'donut'
424
+ };
425
+
426
+ /** Build A2UI's standard Chart `data` ({series:[{name,data:[{value,label}]}]})
427
+ * from Pouchy's number[] | {label?,value}[] so an external A2UI renderer draws
428
+ * the series even without reading `_pouchy`. */
429
+ function toA2UIChartData(data: unknown): {
430
+ series: Array<{ name: string; data: Array<{ value: number; label?: string }> }>;
431
+ } {
432
+ const arr = Array.isArray(data) ? data : [];
433
+ const points = arr.map((d, i) =>
434
+ typeof d === 'number'
435
+ ? { value: d, label: String(i + 1) }
436
+ : {
437
+ value: Number((d as { value?: unknown })?.value ?? 0),
438
+ ...((d as { label?: unknown })?.label !== undefined
439
+ ? { label: String((d as { label?: unknown }).label) }
440
+ : {})
441
+ }
442
+ );
443
+ return { series: [{ name: 'series', data: points }] };
444
+ }
445
+
446
+ function buildComponent(
447
+ id: string,
448
+ node: GenuiNode,
449
+ emit: (n: GenuiNode) => string
450
+ ): A2UIComponent {
451
+ switch (node.type) {
452
+ case 'Text': {
453
+ // A2UI Text's `text` is a DynamicString (literal | {path} | function).
454
+ // A single-placeholder template ("{total}") becomes a real {path}
455
+ // binding so an external renderer interpolates it live; a mixed template
456
+ // stays literal (no A2UI inline-interp form). Tone + the literal-vs-
457
+ // template distinction ride `_pouchy` so the round-trip is exact.
458
+ const soleKey = node.template ? singlePlaceholderKey(node.template) : null;
459
+ const text: A2UIValue = soleKey ? ptr(soleKey) : (node.template ?? node.text ?? '');
460
+ return {
461
+ id,
462
+ component: A2UI.textComponent,
463
+ text,
464
+ _pouchy: { kind: node.template ? 'template' : 'text', ...(node.tone ? { tone: node.tone } : {}) }
465
+ };
466
+ }
467
+ case 'Button': {
468
+ // A2UI Button references a child component (the label Text).
469
+ const labelId = emit({ type: 'Text', text: node.label });
470
+ return {
471
+ id,
472
+ component: A2UI.buttonComponent,
473
+ child: labelId,
474
+ action: encodeAction(node.action),
475
+ ...(node.variant ? { variant: node.variant } : {})
476
+ };
477
+ }
478
+ case 'Progress':
479
+ return {
480
+ id,
481
+ component: A2UI.progressComponent,
482
+ value: isStateRefVal(node.value) ? ptr(node.value.$state) : node.value,
483
+ ...(node.max !== undefined ? { max: node.max } : {}),
484
+ ...(node.variant ? { variant: node.variant } : {})
485
+ };
486
+ case 'Icon':
487
+ return { id, component: A2UI.iconComponent, name: node.name, ...(node.size ? { size: node.size } : {}) };
488
+ case 'Slider':
489
+ return {
490
+ id,
491
+ component: A2UI.sliderComponent,
492
+ value: ptr(node.bind),
493
+ ...(node.min !== undefined ? { min: node.min } : {}),
494
+ ...(node.max !== undefined ? { max: node.max } : {}),
495
+ ...(node.label ? { label: node.label } : {}),
496
+ _pouchy: { ...(node.step !== undefined ? { step: node.step } : {}) }
497
+ };
498
+ case 'Switch':
499
+ return {
500
+ id,
501
+ component: A2UI.checkboxComponent,
502
+ value: ptr(node.bind),
503
+ ...(node.label ? { label: node.label } : {}),
504
+ _pouchy: { kind: 'switch' }
505
+ };
506
+ case 'Select':
507
+ // Pouchy Select is single-select → A2UI ChoicePicker variant
508
+ // 'mutuallyExclusive'. placeholder is non-standard → rides `_pouchy`.
509
+ return {
510
+ id,
511
+ component: A2UI.choicePickerComponent,
512
+ value: ptr(node.bind),
513
+ options: node.options,
514
+ variant: 'mutuallyExclusive',
515
+ ...(node.placeholder ? { _pouchy: { placeholder: node.placeholder } } : {})
516
+ };
517
+ case 'TextInput':
518
+ // A2UI TextField uses `variant` (longText | shortText | number |
519
+ // obscured) instead of a multiline flag; placeholder + maxLength are
520
+ // non-standard, so they ride `_pouchy` for the round-trip.
521
+ return {
522
+ id,
523
+ component: A2UI.textFieldComponent,
524
+ value: ptr(node.bind),
525
+ variant: node.multiline ? 'longText' : 'shortText',
526
+ ...(node.label ? { label: node.label } : {}),
527
+ ...(node.placeholder !== undefined || node.maxLength !== undefined
528
+ ? {
529
+ _pouchy: {
530
+ ...(node.placeholder ? { placeholder: node.placeholder } : {}),
531
+ ...(node.maxLength !== undefined ? { maxLength: node.maxLength } : {})
532
+ }
533
+ }
534
+ : {})
535
+ };
536
+ case 'DatePicker':
537
+ return {
538
+ id,
539
+ component: A2UI.dateTimeComponent,
540
+ value: ptr(node.bind),
541
+ ...(node.label ? { label: node.label } : {}),
542
+ ...(node.min ? { min: node.min } : {}),
543
+ ...(node.max ? { max: node.max } : {})
544
+ };
545
+ case 'Image':
546
+ return {
547
+ id,
548
+ component: A2UI.imageComponent,
549
+ url: node.src,
550
+ ...(node.alt ? { description: node.alt } : {}),
551
+ ...(node.ratio !== undefined ? { _pouchy: { ratio: node.ratio } } : {})
552
+ };
553
+ case 'Group': {
554
+ // gap/wrap have no A2UI standard, so they ride `_pouchy` — but only emit
555
+ // the extension when there's something to carry, so plain layout
556
+ // wrappers (and the Column wrappers Tabs/Card synthesize) stay clean
557
+ // instead of shipping an empty `_pouchy: {}`.
558
+ const ext = {
559
+ ...(node.gap !== undefined ? { gap: node.gap } : {}),
560
+ ...(node.wrap ? { wrap: true } : {})
561
+ };
562
+ return {
563
+ id,
564
+ component: node.direction === 'row' ? A2UI.rowComponent : A2UI.columnComponent,
565
+ children: node.children.map(emit),
566
+ ...(Object.keys(ext).length > 0 ? { _pouchy: ext } : {})
567
+ };
568
+ }
569
+ case 'Tabs':
570
+ return {
571
+ id,
572
+ component: A2UI.tabsComponent,
573
+ // A2UI tab = { title, child }; wrap each tab's node list in a Column.
574
+ tabs: node.tabs.map((t) => ({
575
+ title: t.label,
576
+ child: emit({ type: 'Group', direction: 'column', children: t.children })
577
+ })),
578
+ ...(node.bind ? { _pouchy: { bind: node.bind } } : {})
579
+ };
580
+ case 'Accordion':
581
+ return {
582
+ id,
583
+ component: A2UI.accordionComponent,
584
+ _pouchy: {
585
+ sections: node.sections.map((s) => ({
586
+ label: s.label,
587
+ childIds: s.children.map(emit),
588
+ ...(s.open ? { open: true } : {})
589
+ })),
590
+ ...(node.multi ? { multi: true } : {})
591
+ }
592
+ };
593
+ case 'Chart':
594
+ // Emit the STANDARD A2UI Chart shape (chartType + data series) so an
595
+ // external renderer draws it, AND carry Pouchy's richer original (area/pie
596
+ // kinds, raw data, max) in `_pouchy` for a lossless round-trip back.
597
+ return {
598
+ id,
599
+ component: A2UI.chartComponent,
600
+ chartType: A2UI_CHART_TYPE[node.chart] ?? 'bar',
601
+ data: toA2UIChartData(node.data),
602
+ _pouchy: {
603
+ chart: node.chart,
604
+ data: node.data,
605
+ ...(node.max !== undefined ? { max: node.max } : {}),
606
+ ...(node.title ? { title: node.title } : {})
607
+ }
608
+ };
609
+ case 'Table':
610
+ return {
611
+ id,
612
+ component: A2UI.tableComponent,
613
+ columns: node.columns,
614
+ rows: node.rows,
615
+ ...(node.title ? { _pouchy: { title: node.title } } : {})
616
+ };
617
+ case 'Video':
618
+ return {
619
+ id,
620
+ component: A2UI.videoComponent,
621
+ url: node.src,
622
+ ...(node.ratio !== undefined ? { _pouchy: { ratio: node.ratio } } : {})
623
+ };
624
+ case 'AudioPlayer':
625
+ return {
626
+ id,
627
+ component: A2UI.audioComponent,
628
+ url: node.src,
629
+ ...(node.title ? { _pouchy: { title: node.title } } : {})
630
+ };
631
+ case 'Markdown':
632
+ // A2UI's Markdown field is `content` (not `text`).
633
+ return { id, component: A2UI.markdownComponent, content: node.text };
634
+ case 'Divider':
635
+ // A2UI Divider carries an `axis`; Pouchy only renders horizontal rules.
636
+ return { id, component: A2UI.dividerComponent, axis: 'horizontal' };
637
+ case 'Card':
638
+ // A2UI Card is single-child; wrap the node list in a Column and carry
639
+ // the optional title on the extension (A2UI Card has no title slot).
640
+ return {
641
+ id,
642
+ component: A2UI.cardComponent,
643
+ child: emit({ type: 'Group', direction: 'column', children: node.children }),
644
+ ...(node.title ? { _pouchy: { title: node.title } } : {})
645
+ };
646
+ default:
647
+ // Unreachable for validated panels (the exhaustive union above). Kept
648
+ // for LOOSE SDK input (InstantUIInput): an unknown atom degrades to an
649
+ // empty Column instead of crashing the whole projection.
650
+ return { id, component: A2UI.columnComponent, children: [] };
651
+ }
652
+ }
653
+
654
+ const isStateRefVal = (v: unknown): v is { $state: string } =>
655
+ !!v && typeof v === 'object' && typeof (v as { $state?: unknown }).$state === 'string';
656
+
657
+ // ─── fromA2UI: A2UI surface → Instant UI panel ───────────────────────────────
658
+
659
+ export function fromA2UI(surface: A2UISurface): DynamicInterface {
660
+ const { components, root } = surface;
661
+
662
+ const rebuild = (id: string): GenuiNode | null => {
663
+ const c = components[id];
664
+ if (!c) return null;
665
+ const node = rebuildNode(c, rebuild);
666
+ if (!node) return null;
667
+ const when = c._pouchy?.when;
668
+ if (when) (node as GenuiNode).when = when as GenuiNode['when'];
669
+ return node;
670
+ };
671
+
672
+ const rootComp = components[root];
673
+ const nodes: GenuiNode[] = [];
674
+ if (rootComp && Array.isArray(rootComp.children)) {
675
+ for (const childId of rootComp.children as string[]) {
676
+ const n = rebuild(childId);
677
+ if (n) nodes.push(n);
678
+ }
679
+ }
680
+
681
+ const iface: DynamicInterface = { nodes };
682
+ if (surface.pouchy?.title) iface.title = surface.pouchy.title;
683
+ if (surface.dataModel && Object.keys(surface.dataModel).length > 0)
684
+ iface.state = { ...surface.dataModel };
685
+ if (surface.pouchy?.computed) iface.computed = surface.pouchy.computed;
686
+ if (surface.pouchy?.reportChanges) iface.reportChanges = true;
687
+ if (surface.pouchy?.allowedActions) iface.allowedActions = surface.pouchy.allowedActions;
688
+ return iface;
689
+ }
690
+
691
+ /** Decode an A2UI action event back to the embedded action. */
692
+ const decodeAction = (raw: unknown): EmbeddedAction | null => {
693
+ const ev = (raw as { event?: { name?: string; context?: { action?: unknown } } } | null)?.event;
694
+ if (ev?.name === A2UI.pouchyActionEvent && ev.context?.action)
695
+ return ev.context.action as EmbeddedAction;
696
+ return null;
697
+ };
698
+
699
+ const bindFrom = (v: unknown): string => (isPtr(v) ? ptrKey(v) : '');
700
+
701
+ function rebuildNode(c: A2UIComponent, rebuild: (id: string) => GenuiNode | null): GenuiNode | null {
702
+ const p = c._pouchy ?? {};
703
+ switch (c.component) {
704
+ case A2UI.textComponent: {
705
+ // `text` may be a literal string OR a {path} binding (a single-
706
+ // placeholder template); reconstruct the "{key}" template from the
707
+ // pointer in that case.
708
+ let node: GenuiNode;
709
+ if (p.kind === 'template') {
710
+ const tmpl = isPtr(c.text) ? `{${ptrKey(c.text)}}` : typeof c.text === 'string' ? c.text : '';
711
+ node = { type: 'Text', template: tmpl };
712
+ } else {
713
+ node = { type: 'Text', text: typeof c.text === 'string' ? c.text : '' };
714
+ }
715
+ if (p.tone) (node as { tone?: unknown }).tone = p.tone;
716
+ return node;
717
+ }
718
+ case A2UI.buttonComponent: {
719
+ // The label is the child Text component A2UI Button references.
720
+ const child = typeof c.child === 'string' ? rebuild(c.child) : null;
721
+ const label = child && child.type === 'Text' ? (child.template ?? child.text ?? '') : '';
722
+ return {
723
+ type: 'Button',
724
+ label,
725
+ ...(c.variant ? { variant: c.variant as never } : {}),
726
+ action: decodeAction(c.action)
727
+ } as GenuiNode;
728
+ }
729
+ case A2UI.progressComponent:
730
+ return {
731
+ type: 'Progress',
732
+ value: isPtr(c.value) ? { $state: ptrKey(c.value) } : (c.value as number),
733
+ ...(typeof c.max === 'number' ? { max: c.max } : {}),
734
+ ...(c.variant ? { variant: c.variant as never } : {})
735
+ } as GenuiNode;
736
+ case A2UI.iconComponent:
737
+ return {
738
+ type: 'Icon',
739
+ name: String(c.name ?? ''),
740
+ ...(typeof c.size === 'number' ? { size: c.size } : {})
741
+ };
742
+ case A2UI.sliderComponent:
743
+ return {
744
+ type: 'Slider',
745
+ bind: bindFrom(c.value),
746
+ ...(typeof c.min === 'number' ? { min: c.min } : {}),
747
+ ...(typeof c.max === 'number' ? { max: c.max } : {}),
748
+ ...(typeof p.step === 'number' ? { step: p.step } : {}),
749
+ ...(c.label ? { label: c.label as string } : {})
750
+ } as GenuiNode;
751
+ case A2UI.checkboxComponent:
752
+ return {
753
+ type: 'Switch',
754
+ bind: bindFrom(c.value),
755
+ ...(c.label ? { label: c.label as string } : {})
756
+ } as GenuiNode;
757
+ case A2UI.choicePickerComponent:
758
+ // placeholder rides `_pouchy`; `variant` (mutuallyExclusive) is the
759
+ // single-select marker Pouchy already implies, so it's dropped here.
760
+ return {
761
+ type: 'Select',
762
+ bind: bindFrom(c.value),
763
+ options: (c.options as never) ?? [],
764
+ ...(typeof p.placeholder === 'string' ? { placeholder: p.placeholder } : {})
765
+ } as GenuiNode;
766
+ case A2UI.textFieldComponent:
767
+ // multiline ← variant:'longText'; placeholder + maxLength ← `_pouchy`.
768
+ return {
769
+ type: 'TextInput',
770
+ bind: bindFrom(c.value),
771
+ ...(c.label ? { label: c.label as string } : {}),
772
+ ...(typeof p.placeholder === 'string' ? { placeholder: p.placeholder } : {}),
773
+ ...(c.variant === 'longText' ? { multiline: true } : {}),
774
+ ...(typeof p.maxLength === 'number' ? { maxLength: p.maxLength } : {})
775
+ } as GenuiNode;
776
+ case A2UI.dateTimeComponent:
777
+ return {
778
+ type: 'DatePicker',
779
+ bind: bindFrom(c.value),
780
+ ...(c.label ? { label: c.label as string } : {}),
781
+ ...(c.min ? { min: c.min as string } : {}),
782
+ ...(c.max ? { max: c.max as string } : {})
783
+ } as GenuiNode;
784
+ case A2UI.imageComponent:
785
+ return {
786
+ type: 'Image',
787
+ src: String(c.url ?? ''),
788
+ ...(c.description ? { alt: c.description as string } : {}),
789
+ ...(typeof p.ratio === 'number' ? { ratio: p.ratio } : {})
790
+ } as GenuiNode;
791
+ case A2UI.rowComponent:
792
+ case A2UI.columnComponent: {
793
+ const children = Array.isArray(c.children)
794
+ ? (c.children as string[]).map(rebuild).filter((n): n is GenuiNode => n !== null)
795
+ : [];
796
+ return {
797
+ type: 'Group',
798
+ direction: c.component === A2UI.rowComponent ? 'row' : 'column',
799
+ ...(typeof p.gap === 'number' ? { gap: p.gap } : {}),
800
+ ...(p.wrap ? { wrap: true } : {}),
801
+ children
802
+ } as GenuiNode;
803
+ }
804
+ case A2UI.tabsComponent: {
805
+ const tabs = Array.isArray(c.tabs)
806
+ ? (c.tabs as Array<{ title: string; child: string }>).map((t) => {
807
+ const wrapper = rebuild(t.child);
808
+ const children = wrapper && wrapper.type === 'Group' ? wrapper.children : [];
809
+ return { label: t.title, children };
810
+ })
811
+ : [];
812
+ return { type: 'Tabs', tabs, ...(p.bind ? { bind: p.bind as string } : {}) } as GenuiNode;
813
+ }
814
+ case A2UI.accordionComponent: {
815
+ const sections = Array.isArray(p.sections)
816
+ ? (p.sections as Array<{ label: string; childIds: string[]; open?: boolean }>).map((s) => ({
817
+ label: s.label,
818
+ children: s.childIds.map(rebuild).filter((n): n is GenuiNode => n !== null),
819
+ ...(s.open ? { open: true } : {})
820
+ }))
821
+ : [];
822
+ return { type: 'Accordion', sections, ...(p.multi ? { multi: true } : {}) } as GenuiNode;
823
+ }
824
+ case A2UI.chartComponent: {
825
+ // Prefer Pouchy's lossless `_pouchy` original; when absent (an external
826
+ // A2UI Chart), reconstruct from the standard chartType + data series.
827
+ if (p.chart !== undefined || p.data !== undefined) {
828
+ return {
829
+ type: 'Chart',
830
+ chart: p.chart as never,
831
+ data: (p.data as never) ?? [],
832
+ ...(typeof p.max === 'number' ? { max: p.max } : {}),
833
+ ...(p.title ? { title: p.title as string } : {})
834
+ } as GenuiNode;
835
+ }
836
+ const series = (
837
+ c.data as { series?: Array<{ data?: Array<{ value?: number; label?: string }> }> }
838
+ )?.series?.[0]?.data;
839
+ const data = Array.isArray(series)
840
+ ? series.map((pt) => ({ value: Number(pt?.value ?? 0), ...(pt?.label ? { label: pt.label } : {}) }))
841
+ : [];
842
+ return {
843
+ type: 'Chart',
844
+ chart: (c.chartType as never) ?? ('bar' as never),
845
+ data: data as never
846
+ } as GenuiNode;
847
+ }
848
+ case A2UI.tableComponent:
849
+ return {
850
+ type: 'Table',
851
+ columns: (c.columns as never) ?? [],
852
+ rows: (c.rows as never) ?? [],
853
+ ...(p.title ? { title: p.title as string } : {})
854
+ } as GenuiNode;
855
+ case A2UI.videoComponent:
856
+ return {
857
+ type: 'Video',
858
+ src: String(c.url ?? ''),
859
+ ...(typeof p.ratio === 'number' ? { ratio: p.ratio } : {})
860
+ } as GenuiNode;
861
+ case A2UI.audioComponent:
862
+ return {
863
+ type: 'AudioPlayer',
864
+ src: String(c.url ?? ''),
865
+ ...(p.title ? { title: p.title as string } : {})
866
+ } as GenuiNode;
867
+ case A2UI.markdownComponent:
868
+ // Prefer A2UI's `content`; fall back to `text` when ingesting a surface
869
+ // that used the older field name.
870
+ return { type: 'Markdown', text: String(c.content ?? c.text ?? '') } as GenuiNode;
871
+ case A2UI.dividerComponent:
872
+ return { type: 'Divider' };
873
+ case A2UI.cardComponent: {
874
+ const wrapper = typeof c.child === 'string' ? rebuild(c.child) : null;
875
+ const children = wrapper && wrapper.type === 'Group' ? wrapper.children : [];
876
+ return { type: 'Card', ...(p.title ? { title: p.title as string } : {}), children } as GenuiNode;
877
+ }
878
+ default:
879
+ return null;
880
+ }
881
+ }