poetry-agent 0.0.2

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +51 -0
  5. data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
  6. data/app/javascript/poetry/agent/adapter.js +77 -0
  7. data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
  8. data/app/javascript/poetry/agent/index.js +41 -0
  9. data/app/javascript/poetry/agent/stream_actions.js +65 -0
  10. data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
  11. data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
  12. data/config/controllers_manifest.json +82 -0
  13. data/config/importmap.rb +10 -0
  14. data/exe/poetry-agent +28 -0
  15. data/lib/poetry/agent/a2ui/catalog.rb +289 -0
  16. data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
  17. data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
  18. data/lib/poetry/agent/a2ui/checks.rb +45 -0
  19. data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
  20. data/lib/poetry/agent/a2ui/expression.rb +175 -0
  21. data/lib/poetry/agent/a2ui/functions.rb +417 -0
  22. data/lib/poetry/agent/a2ui/markdown.rb +63 -0
  23. data/lib/poetry/agent/a2ui/pointer.rb +113 -0
  24. data/lib/poetry/agent/a2ui/protocol.rb +12 -0
  25. data/lib/poetry/agent/a2ui/renderer.rb +242 -0
  26. data/lib/poetry/agent/a2ui/session.rb +302 -0
  27. data/lib/poetry/agent/a2ui/streams.rb +82 -0
  28. data/lib/poetry/agent/a2ui/surface.rb +352 -0
  29. data/lib/poetry/agent/a2ui.rb +48 -0
  30. data/lib/poetry/agent/agui/client.rb +69 -0
  31. data/lib/poetry/agent/agui/json_patch.rb +137 -0
  32. data/lib/poetry/agent/agui/relay.rb +105 -0
  33. data/lib/poetry/agent/agui/run_input.rb +83 -0
  34. data/lib/poetry/agent/agui/sse.rb +97 -0
  35. data/lib/poetry/agent/agui/transcript.rb +540 -0
  36. data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
  37. data/lib/poetry/agent/agui.rb +87 -0
  38. data/lib/poetry/agent/config.rb +49 -0
  39. data/lib/poetry/agent/engine.rb +37 -0
  40. data/lib/poetry/agent/mcp/bundled.rb +54 -0
  41. data/lib/poetry/agent/mcp/http.rb +89 -0
  42. data/lib/poetry/agent/mcp/server.rb +962 -0
  43. data/lib/poetry/agent/version.rb +8 -0
  44. data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
  45. data/lib/poetry/agent/webmcp.rb +37 -0
  46. data/lib/poetry/agent.rb +66 -0
  47. data/lib/poetry-agent.rb +4 -0
  48. metadata +117 -0
@@ -0,0 +1,460 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pointer"
4
+ require_relative "../markdown"
5
+
6
+ module Poetry
7
+ module Agent
8
+ module A2UI
9
+ # The catalog bindings a {Surface} renders through: how a catalog's
10
+ # components reference their children, which of them are bound
11
+ # inputs, and how each one renders with Poetry.
12
+ module Catalogs
13
+ # The A2UI basic catalog (v1.0) rendered with Poetry: every one of
14
+ # its components maps onto a library component or a plain element,
15
+ # its enums onto the library's axes, its bound inputs onto named
16
+ # form controls, and its `checks` onto native constraint
17
+ # attributes where the browser can enforce them.
18
+ class Basic
19
+ # The catalog id agents name in `createSurface`.
20
+ ID = "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json"
21
+
22
+ # Main-axis alignment classes by the catalog's `justify` values.
23
+ JUSTIFY = { "start" => "justify-start", "center" => "justify-center", "end" => "justify-end",
24
+ "spaceBetween" => "justify-between", "spaceAround" => "justify-around",
25
+ "spaceEvenly" => "justify-evenly", "stretch" => "justify-stretch" }.freeze
26
+ # Cross-axis alignment classes by the catalog's `align` values.
27
+ ALIGN = { "start" => "items-start", "center" => "items-center", "end" => "items-end",
28
+ "stretch" => "items-stretch" }.freeze
29
+ # Object-fit classes by the Image `fit` values.
30
+ FIT = { "contain" => "object-contain", "cover" => "object-cover", "fill" => "object-fill",
31
+ "none" => "object-none", "scaleDown" => "object-scale-down" }.freeze
32
+ # Sizing classes by the Image `variant` values.
33
+ IMAGE_VARIANTS = { "icon" => "size-6", "avatar" => "size-10 rounded-full",
34
+ "smallFeature" => "w-full max-w-32", "mediumFeature" => "w-full max-w-sm",
35
+ "largeFeature" => "w-full max-w-2xl", "header" => "w-full" }.freeze
36
+ # Poetry's Button variant by the catalog's Button `variant` values.
37
+ BUTTON_VARIANTS = { "primary" => :default, "default" => :outline, "borderless" => :ghost }.freeze
38
+ # Lucide names for the Material-style icon names agents tend to
39
+ # emit; anything else converts camelCase to kebab-case as is.
40
+ ICON_ALIASES = { "skip-previous" => "skip-back", "skip-next" => "skip-forward", "play-arrow" => "play",
41
+ "favorite" => "heart", "favorite-border" => "heart", "close" => "x",
42
+ "arrow-back" => "arrow-left", "arrow-forward" => "arrow-right",
43
+ "more-vert" => "ellipsis-vertical", "more-horiz" => "ellipsis", "delete" => "trash-2",
44
+ "edit" => "pencil", "add" => "plus", "remove" => "minus", "notifications" => "bell",
45
+ "person" => "user", "email" => "mail", "schedule" => "clock", "event" => "calendar",
46
+ "location-on" => "map-pin", "visibility" => "eye", "visibility-off" => "eye-off",
47
+ "home" => "house", "shopping-cart" => "shopping-cart" }.freeze
48
+ # A ChoicePicker's resolved options, binding, selection, and label.
49
+ Choice = Struct.new(:options, :path, :selected, :label, keyword_init: true)
50
+ # The bound kinds of the input components.
51
+ INPUT_KINDS = { "TextField" => :string, "CheckBox" => :boolean, "Slider" => :number,
52
+ "ChoicePicker" => :string_list, "DateTimeInput" => :string }.freeze
53
+
54
+ # @return [String]
55
+ def id
56
+ ID
57
+ end
58
+
59
+ # @return [Functions] the basic catalog's function set
60
+ def functions
61
+ Functions.basic
62
+ end
63
+
64
+ # The child references of a component (ids, id lists, templates).
65
+ #
66
+ # @param component [Hash]
67
+ # @return [Array<Object>]
68
+ def references(component)
69
+ case component["component"]
70
+ when "Row", "Column", "List" then [component["children"]]
71
+ when "Card", "Button" then [component["child"]]
72
+ when "Modal" then [component["trigger"], component["content"]]
73
+ when "Tabs" then Array(component["tabs"]).map { |tab| tab.is_a?(Hash) ? tab["child"] : nil }
74
+ else []
75
+ end.compact
76
+ end
77
+
78
+ # The bound input of a component, if it is one.
79
+ #
80
+ # @param component [Hash]
81
+ # @param scope [String, nil]
82
+ # @return [Array<Hash>] `{ path:, kind: }`
83
+ def inputs(component, scope)
84
+ kind = INPUT_KINDS[component["component"]]
85
+ path = binding_path(component["value"])
86
+ return [] unless kind && path
87
+
88
+ kind = :number if component["component"] == "TextField" && component["variant"] == "number"
89
+ [{ path: Pointer.absolute(path, scope), kind: kind }]
90
+ end
91
+
92
+ # @param component [Hash]
93
+ # @param scope [String, nil]
94
+ # @param renderer [Renderer]
95
+ # @return [String] HTML
96
+ def render(component, scope, renderer)
97
+ name = component["component"]
98
+ method = :"render_#{name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}"
99
+ return renderer.warn("unknown basic catalog component #{name.inspect}") unless respond_to?(method, true)
100
+
101
+ send(method, component, scope, renderer)
102
+ end
103
+
104
+ private
105
+
106
+ def render_text(component, scope, renderer)
107
+ text = renderer.text(component["text"], scope)
108
+ if component["variant"] == "caption"
109
+ renderer.view.tag.div(renderer.markdown(text), class: "text-sm text-muted-foreground")
110
+ else
111
+ renderer.component(Poetry::Ui::Typeset::Component) { renderer.markdown(text) }
112
+ end
113
+ end
114
+
115
+ def render_image(component, scope, renderer)
116
+ url = renderer.text(component["url"], scope)
117
+ return renderer.blank if url.empty?
118
+
119
+ classes = ["rounded-md", FIT.fetch(component["fit"], "object-fill"),
120
+ IMAGE_VARIANTS.fetch(component["variant"], IMAGE_VARIANTS["mediumFeature"])]
121
+ renderer.view.tag.img(src: url, alt: renderer.text(component["description"], scope),
122
+ class: classes.join(" "), loading: "lazy")
123
+ end
124
+
125
+ def render_icon(component, scope, renderer)
126
+ name = icon_name(renderer.text(component["name"], scope))
127
+ return renderer.blank if name.empty?
128
+
129
+ renderer.component(Poetry::Ui::Icon::Component, name: name, class: "size-5")
130
+ end
131
+
132
+ def icon_name(raw)
133
+ kebab = raw.strip.gsub(/([a-z0-9])([A-Z])/, '\1-\2').tr("_ ", "--").downcase
134
+ ICON_ALIASES.fetch(kebab, kebab)
135
+ end
136
+
137
+ def render_video(component, scope, renderer)
138
+ url = renderer.text(component["url"], scope)
139
+ return renderer.blank if url.empty?
140
+
141
+ poster = renderer.text(component["posterUrl"], scope)
142
+ renderer.view.tag.video(controls: true, src: url, poster: poster.empty? ? nil : poster,
143
+ class: "w-full rounded-md")
144
+ end
145
+
146
+ def render_audio_player(component, scope, renderer)
147
+ url = renderer.text(component["url"], scope)
148
+ return renderer.blank if url.empty?
149
+
150
+ description = renderer.text(component["description"], scope)
151
+ renderer.view.tag.audio(controls: true, src: url, "aria-label": description.empty? ? nil : description,
152
+ class: "w-full")
153
+ end
154
+
155
+ def render_row(component, scope, renderer)
156
+ flex(component, scope, renderer, "flex-row")
157
+ end
158
+
159
+ def render_column(component, scope, renderer)
160
+ flex(component, scope, renderer, "flex-col")
161
+ end
162
+
163
+ def flex(component, scope, renderer, direction)
164
+ classes = ["flex", direction, "gap-4", JUSTIFY.fetch(component["justify"], "justify-start"),
165
+ ALIGN.fetch(component["align"], "items-stretch")]
166
+ renderer.view.tag.div(weighted_children(component["children"], scope, renderer),
167
+ class: classes.join(" "), "aria-label": renderer.aria_label(component, scope))
168
+ end
169
+
170
+ # A child's `weight` is its flex-grow share of the container.
171
+ def weighted_children(reference, scope, renderer)
172
+ items = renderer.surface.expand(reference, scope).map do |child_id, child_scope|
173
+ html = renderer.render_component(child_id, child_scope)
174
+ weight = renderer.surface.component(child_id)&.fetch("weight", nil)
175
+ weight.is_a?(Numeric) ? renderer.view.tag.div(html, style: "flex: #{weight} 1 0%") : html
176
+ end
177
+ renderer.view.safe_join(items)
178
+ end
179
+
180
+ def render_list(component, scope, renderer)
181
+ horizontal = component["direction"] == "horizontal"
182
+ classes = ["flex gap-2", horizontal ? "flex-row overflow-x-auto" : "flex-col overflow-y-auto",
183
+ ALIGN.fetch(component["align"], "items-stretch")]
184
+ renderer.view.tag.div(renderer.render_children(component["children"], scope), class: classes.join(" "))
185
+ end
186
+
187
+ def render_card(component, scope, renderer)
188
+ renderer.component(Poetry::Ui::Card::Component) { renderer.render_children(component["child"], scope) }
189
+ end
190
+
191
+ def render_tabs(component, scope, renderer)
192
+ tabs = Array(component["tabs"]).grep(Hash)
193
+ return renderer.blank if tabs.empty?
194
+
195
+ label = renderer.aria_label(component, scope) || "Tabs"
196
+ renderer.component(Poetry::Ui::Tabs::Component, label: label) do |instance|
197
+ tabs.each_with_index do |tab, index|
198
+ instance.with_tab(renderer.text(tab["title"], scope), value: "tab-#{index}") do
199
+ renderer.render_children(tab["child"], scope)
200
+ end
201
+ end
202
+ nil
203
+ end
204
+ end
205
+
206
+ def render_divider(component, _scope, renderer)
207
+ orientation = component["axis"] == "vertical" ? :vertical : :horizontal
208
+ renderer.component(Poetry::Ui::Separator::Component, orientation: orientation)
209
+ end
210
+
211
+ # The trigger's Button collapses into the dialog's own trigger
212
+ # button (no nested interactives); anything else renders inside it.
213
+ def render_modal(component, scope, renderer)
214
+ trigger = renderer.surface.component(component["trigger"])
215
+ return renderer.warn("Modal #{component["id"].inspect}: unknown trigger") unless trigger
216
+
217
+ title = renderer.aria_label(component, scope) || label_of(trigger, scope, renderer) || "Dialog"
218
+ renderer.component(Poetry::Ui::Dialog::Component) do |dialog|
219
+ dialog.with_trigger(variant: BUTTON_VARIANTS.fetch(trigger["variant"], :outline)) do
220
+ if trigger["component"] == "Button"
221
+ button_content(trigger, scope, renderer)
222
+ else
223
+ renderer.render_component(component["trigger"], scope)
224
+ end
225
+ end
226
+ dialog.with_title { title }
227
+ renderer.render_children(component["content"], scope)
228
+ end
229
+ end
230
+
231
+ def render_button(component, scope, renderer)
232
+ attributes = { variant: BUTTON_VARIANTS.fetch(component["variant"], :outline) }
233
+ label = renderer.aria_label(component, scope)
234
+ attributes[:aria] = { label: label } if label
235
+ attributes.merge!(action_attributes(component, scope, renderer))
236
+ keyed(component, scope, renderer, attributes)
237
+ button = renderer.component(Poetry::Ui::Button::Component, attributes) do
238
+ button_content(component, scope, renderer)
239
+ end
240
+ with_error(component, scope, renderer, button)
241
+ end
242
+
243
+ # An agent event submits; `openUrl` links (the function validates
244
+ # the URL); any other local action has no renderer-side meaning.
245
+ def action_attributes(component, scope, renderer)
246
+ action = component["action"]
247
+ return { type: :button } unless action.is_a?(Hash)
248
+ return renderer.submit_attributes(component, scope) if action["event"].is_a?(Hash)
249
+
250
+ call = action["functionCall"]
251
+ return { type: :button } unless call.is_a?(Hash)
252
+
253
+ if call["call"] == "openUrl"
254
+ url = renderer.call_function("openUrl", call["args"], scope)
255
+ return { href: url } if url
256
+ else
257
+ renderer.warn("Button #{component["id"].inspect}: local action #{call["call"].inspect} is not supported")
258
+ end
259
+ { type: :button, disabled: true }
260
+ end
261
+
262
+ # A checked control carries an error slot the server fills on a
263
+ # rejected action and the client-side evaluator fills as the user
264
+ # types; an unchecked control without a failure renders bare.
265
+ def with_error(component, scope, renderer, control)
266
+ error = renderer.error_for(component, scope)
267
+ return control unless error || component["checks"].is_a?(Array)
268
+
269
+ note = renderer.view.tag.p(error, id: "#{renderer.control_id(component, scope)}-error",
270
+ class: "text-sm text-destructive", role: "alert",
271
+ hidden: error.nil?, data: { a2ui_error_for: renderer.current_key })
272
+ renderer.view.safe_join([control, note])
273
+ end
274
+
275
+ # The attributes that let the client-side evaluator find a control
276
+ # and read its error slot.
277
+ def keyed(component, scope, renderer, attributes)
278
+ attributes[:data] = (attributes[:data] || {}).merge(a2ui_key: renderer.current_key)
279
+ if component["checks"].is_a?(Array)
280
+ described = "#{renderer.control_id(component, scope)}-error"
281
+ attributes[:aria] = (attributes[:aria] || {}).merge(describedby: described)
282
+ end
283
+ attributes
284
+ end
285
+
286
+ # A Text child becomes the button's label (plain, no block markup).
287
+ def button_content(component, scope, renderer)
288
+ label = label_of(component, scope, renderer)
289
+ label || renderer.render_children(component["child"], scope)
290
+ end
291
+
292
+ def label_of(component, scope, renderer)
293
+ child = renderer.surface.component(component["child"])
294
+ return unless child && child["component"] == "Text"
295
+
296
+ Markdown.strip(renderer.text(child["text"], scope))
297
+ end
298
+
299
+ def render_text_field(component, scope, renderer)
300
+ path = binding_path(component["value"])
301
+ attributes = { id: renderer.control_id(component, scope), value: renderer.text(component["value"], scope) }
302
+ attributes[:name] = renderer.input_name(path, scope) if path
303
+ placeholder = renderer.text(component["placeholder"], scope)
304
+ attributes[:placeholder] = placeholder unless placeholder.empty?
305
+ attributes.merge!(check_attributes(component))
306
+ error = renderer.error_for(component, scope)
307
+ attributes[:invalid] = true if error
308
+ keyed(component, scope, renderer, attributes)
309
+ control = if component["variant"] == "longText"
310
+ renderer.component(Poetry::Ui::Textarea::Component, attributes.except(:type).merge(rows: 3))
311
+ else
312
+ attributes[:type] ||= { "number" => :number, "obscured" => :password }.fetch(
313
+ component["variant"], :text
314
+ )
315
+ renderer.component(Poetry::Ui::Input::Component, attributes)
316
+ end
317
+ field = { id: attributes[:id], label_text: renderer.text(component["label"], scope) }
318
+ field[:required] = true if attributes[:required]
319
+ field[:invalid] = true if error
320
+ with_error(component, scope, renderer, renderer.component(Poetry::Ui::Field::Component, field) { control })
321
+ end
322
+
323
+ def render_check_box(component, scope, renderer)
324
+ path = binding_path(component["value"])
325
+ attributes = { label: renderer.text(component["label"], scope), value: "true", unchecked_value: "false",
326
+ checked: renderer.resolve(component["value"], scope) == true }
327
+ attributes[:name] = renderer.input_name(path, scope) if path
328
+ attributes[:required] = true if check_attributes(component)[:required]
329
+ keyed(component, scope, renderer, attributes)
330
+ with_error(component, scope, renderer, renderer.component(Poetry::Ui::Checkbox::Component, attributes))
331
+ end
332
+
333
+ # `filterable` picks through a Combobox (single or multiple); chips
334
+ # lay the choices out as a wrapping row; the default is a radio
335
+ # group or a checkbox list.
336
+ # `filterable` picks through a Combobox (single or multiple); chips
337
+ # lay the choices out as a wrapping row; the default is a radio
338
+ # group or a checkbox list.
339
+ def render_choice_picker(component, scope, renderer)
340
+ choice = Choice.new(options: Array(component["options"]).grep(Hash), path: binding_path(component["value"]),
341
+ selected: Array(renderer.resolve(component["value"], scope)).map(&:to_s),
342
+ label: renderer.text(component["label"], scope))
343
+ multiple = component["variant"] == "multipleSelection"
344
+ chips = component["displayStyle"] == "chips"
345
+ control = if component["filterable"] == true
346
+ choice_combobox(choice, multiple, scope, renderer)
347
+ elsif multiple
348
+ choice_boxes(choice, chips, scope, renderer)
349
+ else
350
+ attributes = keyed(component, scope, renderer,
351
+ { label: choice.label, value: choice.selected.first })
352
+ attributes[:orientation] = :horizontal if chips
353
+ attributes[:name] = renderer.input_name(choice.path, scope) if choice.path
354
+ renderer.component(Poetry::Ui::RadioGroup::Component, attributes) do |group|
355
+ choice.options.each do |option|
356
+ group.with_item(label: renderer.text(option["label"], scope), value: option["value"].to_s)
357
+ end
358
+ nil
359
+ end
360
+ end
361
+ with_error(component, scope, renderer, control)
362
+ end
363
+
364
+ def choice_combobox(choice, multiple, scope, renderer)
365
+ attributes = { multiple: multiple, placeholder: choice.label, aria: { label: choice.label },
366
+ value: multiple ? choice.selected : choice.selected.first }
367
+ attributes[:name] = renderer.input_name(choice.path, scope) if choice.path
368
+ renderer.component(Poetry::Ui::Combobox::Component, attributes) do |combobox|
369
+ choice.options.each do |option|
370
+ combobox.with_item(value: option["value"].to_s) { renderer.text(option["label"], scope) }
371
+ end
372
+ nil
373
+ end
374
+ end
375
+
376
+ # A checkbox per option under one list name; the leading empty
377
+ # value keeps "nothing checked" submittable. Chips wrap the row.
378
+ def choice_boxes(choice, chips, scope, renderer)
379
+ name = choice.path && "#{renderer.input_name(choice.path, scope)}[]"
380
+ items = [renderer.view.tag.legend(choice.label, class: "mb-2 text-sm font-medium")]
381
+ items << renderer.view.hidden_field_tag(name, "", id: nil) if name
382
+ choice.options.each do |option|
383
+ value = option["value"].to_s
384
+ attributes = { label: renderer.text(option["label"], scope), value: value,
385
+ checked: choice.selected.include?(value) }
386
+ attributes[:name] = name if name
387
+ box = renderer.component(Poetry::Ui::Checkbox::Component, attributes, suffix: value)
388
+ items << (chips ? renderer.view.tag.span(box, class: "rounded-full border px-3 py-1") : box)
389
+ end
390
+ classes = chips ? "flex flex-row flex-wrap items-center gap-2 [&>legend]:w-full" : "flex flex-col gap-2"
391
+ renderer.view.tag.fieldset(renderer.view.safe_join(items), class: classes)
392
+ end
393
+
394
+ def render_slider(component, scope, renderer)
395
+ path = binding_path(component["value"])
396
+ min = component["min"].is_a?(Numeric) ? component["min"] : 0
397
+ max = component["max"].is_a?(Numeric) ? component["max"] : 100
398
+ value = renderer.resolve(component["value"], scope)
399
+ label = renderer.text(component["label"], scope)
400
+ label = renderer.aria_label(component, scope) || component["id"].to_s.tr("_", " ") if label.empty?
401
+ attributes = { label: label, min: min, max: max, value: value.is_a?(Numeric) ? value : min }
402
+ attributes[:name] = renderer.input_name(path, scope) if path
403
+ steps = component["steps"]
404
+ attributes[:step] = step_size(min, max, steps) if steps.is_a?(Integer) && steps.positive?
405
+ keyed(component, scope, renderer, attributes)
406
+ with_error(component, scope, renderer, renderer.component(Poetry::Ui::Slider::Component, attributes))
407
+ end
408
+
409
+ def step_size(min, max, steps)
410
+ size = (max - min).to_f / steps
411
+ size == size.floor ? size.to_i : size
412
+ end
413
+
414
+ def render_date_time_input(component, scope, renderer)
415
+ path = binding_path(component["value"])
416
+ type = if component["enableTime"] && component["enableDate"] then "datetime-local"
417
+ elsif component["enableTime"] then "time"
418
+ else "date"
419
+ end
420
+ attributes = { id: renderer.control_id(component, scope), type: type,
421
+ value: renderer.text(component["value"], scope) }
422
+ attributes[:name] = renderer.input_name(path, scope) if path
423
+ %w[min max].each do |bound|
424
+ limit = renderer.text(component[bound], scope)
425
+ attributes[bound.to_sym] = limit unless limit.empty?
426
+ end
427
+ attributes[:required] = true if check_attributes(component)[:required]
428
+ keyed(component, scope, renderer, attributes)
429
+ with_error(component, scope, renderer, renderer.component(Poetry::Ui::Input::Component, attributes))
430
+ end
431
+
432
+ # The checks the browser can enforce as constraint attributes.
433
+ def check_attributes(component)
434
+ attributes = {}
435
+ Array(component["checks"]).each do |check|
436
+ condition = check.is_a?(Hash) ? check["condition"] : nil
437
+ next unless condition.is_a?(Hash) && condition["call"]
438
+
439
+ args = condition["args"].is_a?(Hash) ? condition["args"] : {}
440
+ case condition["call"]
441
+ when "required" then attributes[:required] = true
442
+ when "regex" then attributes[:pattern] = args["pattern"] if args["pattern"].is_a?(String)
443
+ when "length"
444
+ attributes[:minlength] = args["min"] if args["min"].is_a?(Integer)
445
+ attributes[:maxlength] = args["max"] if args["max"].is_a?(Integer)
446
+ when "email" then attributes[:type] = :email
447
+ when "numeric" then attributes[:inputmode] = "decimal"
448
+ end
449
+ end
450
+ attributes
451
+ end
452
+
453
+ def binding_path(value)
454
+ value.is_a?(Hash) && value["path"].is_a?(String) ? value["path"] : nil
455
+ end
456
+ end
457
+ end
458
+ end
459
+ end
460
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pointer"
4
+ require_relative "../catalog"
5
+
6
+ module Poetry
7
+ module Agent
8
+ module A2UI
9
+ module Catalogs
10
+ # Poetry's own catalog (the {Catalog} projection) rendered back
11
+ # through the registry: a component name resolves to its registry
12
+ # entry, style axes and options become constructor keywords, slot
13
+ # properties drive the generated slot setters, `text` is the
14
+ # content block, `children` and `child` nest, and a bound `value`
15
+ # names the control for the surface form. The registry is the one
16
+ # source: whatever it declares renders, nothing is hand-mapped.
17
+ class Native
18
+ # The bound options an input component may carry.
19
+ BOUND_OPTIONS = %w[value checked values].freeze
20
+ # Registry option types by bound kind.
21
+ KINDS = { "boolean" => :boolean, "checked_state" => :boolean, "integer" => :number, "number" => :number,
22
+ "float" => :number, "list" => :string_list }.freeze
23
+
24
+ # @return [String]
25
+ attr_reader :id
26
+
27
+ # @param entries [Hash{String => Hash}, nil] registry entries by path
28
+ # (the poetry-ui registry when nil)
29
+ # @param id [String] the catalog id this binding answers to
30
+ def initialize(entries: nil, id: Catalog::DEFAULT_ID)
31
+ @entries = entries
32
+ @id = id
33
+ end
34
+
35
+ # @return [Functions] the function set Poetry's catalog declares (the basic one)
36
+ def functions
37
+ Functions.basic
38
+ end
39
+
40
+ # @return [Hash{String => Hash}] registry entries by path
41
+ def entries
42
+ @entries ||= Catalog.load_entries
43
+ end
44
+
45
+ # @return [Hash{String => Array(String, Hash)}] `[path, entry]` by component name
46
+ def by_name
47
+ @by_name ||= entries.to_h { |path, entry| [Catalog.component_name(path), [path, entry]] }
48
+ end
49
+
50
+ # The child references: `child`, `children`, and every slot property.
51
+ #
52
+ # @param component [Hash]
53
+ # @return [Array<Object>]
54
+ def references(component)
55
+ _path, entry = lookup(component)
56
+ references = [component["child"], component["children"]]
57
+ Array(entry && entry["slots"]).each { |slot| references << component[slot["name"]] }
58
+ references.compact
59
+ end
60
+
61
+ # @param component [Hash]
62
+ # @param scope [String, nil]
63
+ # @return [Array<Hash>] `{ path:, kind: }` for each bound option
64
+ def inputs(component, scope)
65
+ _path, entry = lookup(component)
66
+ return [] unless entry
67
+
68
+ BOUND_OPTIONS.filter_map do |name|
69
+ value = component[name]
70
+ next unless value.is_a?(Hash) && value["path"].is_a?(String)
71
+
72
+ option = Array(entry["options"]).find { |candidate| candidate["name"] == name }
73
+ next unless option
74
+
75
+ { path: Pointer.absolute(value["path"], scope), kind: KINDS.fetch(option["type"].to_s, :string) }
76
+ end
77
+ end
78
+
79
+ # @param component [Hash]
80
+ # @param scope [String, nil]
81
+ # @param renderer [Renderer]
82
+ # @return [String] HTML
83
+ def render(component, scope, renderer)
84
+ _path, entry = lookup(component)
85
+ return renderer.warn("unknown component #{component["component"].inspect}") unless entry
86
+
87
+ klass = Object.const_get(entry["class_name"])
88
+ renderer.component(klass, attributes(component, entry, scope, renderer)) do |instance|
89
+ fill_slots(instance, component, entry, scope, renderer)
90
+ content(component, scope, renderer)
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def lookup(component)
97
+ by_name[component["component"].to_s] || [nil, nil]
98
+ end
99
+
100
+ def attributes(component, entry, scope, renderer)
101
+ attributes = {}
102
+ Array(entry["styles"]).each do |axis|
103
+ value = component[axis["name"]]
104
+ attributes[axis["name"].to_sym] = value.to_sym if value.is_a?(String)
105
+ end
106
+ option_names = Array(entry["options"]).map { |option| option["name"] }
107
+ option_names.each do |name|
108
+ next if Catalog.skipped_option?(name) || !component.key?(name)
109
+
110
+ attributes[name.to_sym] = renderer.resolve(component[name], scope)
111
+ end
112
+ bound = BOUND_OPTIONS.find { |name| renderer.surface.binding?(component[name]) }
113
+ if bound && option_names.include?("name") && !component.key?("name")
114
+ attributes[:name] = renderer.input_name(component[bound]["path"], scope)
115
+ end
116
+ label = renderer.aria_label(component, scope)
117
+ attributes[:aria] = { label: label } if label
118
+ attributes.merge!(action_attributes(component, scope, renderer)) if component["action"].is_a?(Hash)
119
+ if bound || component["action"]
120
+ attributes[:data] = (attributes[:data] || {}).merge(a2ui_key: renderer.current_key)
121
+ end
122
+ attributes
123
+ end
124
+
125
+ def action_attributes(component, scope, renderer)
126
+ action = component["action"]
127
+ return renderer.submit_attributes(component, scope) if action["event"].is_a?(Hash)
128
+
129
+ call = action["functionCall"]
130
+ if call.is_a?(Hash) && call["call"] == "openUrl"
131
+ url = renderer.call_function("openUrl", call["args"], scope)
132
+ return { href: url } if url
133
+ else
134
+ renderer.warn("#{component["component"]} #{component["id"].inspect}: unsupported local action")
135
+ end
136
+ { type: :button, disabled: true }
137
+ end
138
+
139
+ # A slot property holds one id (renders_one) or ids (renders_many).
140
+ # A positional setter (Tabs' `with_tab(title, value:)`) takes the
141
+ # child's `text` as its title and the child's children as its panel.
142
+ def fill_slots(instance, component, entry, scope, renderer)
143
+ Array(entry["slots"]).each do |slot|
144
+ target = component[slot["name"]]
145
+ next if target.nil?
146
+
147
+ setter = slot["many"] ? "with_#{slot["name"].singularize}" : "with_#{slot["name"]}"
148
+ positional = slot["setter_args"].is_a?(Hash) ? slot["setter_args"].values.first.to_i : 0
149
+ renderer.surface.expand(target, scope).each do |child_id, child_scope|
150
+ if positional.positive?
151
+ child = renderer.surface.component(child_id) || {}
152
+ title = renderer.text(child["text"], child_scope)
153
+ instance.public_send(setter, title, value: child_id) do
154
+ renderer.render_children(child["children"] || child["child"], child_scope)
155
+ end
156
+ else
157
+ instance.public_send(setter) { renderer.render_component(child_id, child_scope) }
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ def content(component, scope, renderer)
164
+ if component.key?("text")
165
+ renderer.view.safe_join([renderer.text(component["text"], scope)])
166
+ elsif component.key?("children")
167
+ renderer.render_children(component["children"], scope)
168
+ elsif component.key?("child")
169
+ renderer.render_children(component["child"], scope)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end