Skip to main content

adk_ui/
schema.rs

1use crate::surface_runtime::SurfaceRef;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6pub const MIME_TYPE_UI: &str = "application/vnd.adk.ui+json";
7pub const MIME_TYPE_UI_UPDATE: &str = "application/vnd.adk.ui.update+json";
8pub const MIME_TYPE_UI_EVENT: &str = "application/vnd.adk.ui.event+json";
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum Component {
13    // Atoms
14    Text(Text),
15    Button(Button),
16    Icon(Icon),
17    Image(Image),
18    Badge(Badge),
19
20    // Inputs
21    TextInput(TextInput),
22    NumberInput(NumberInput),
23    Select(Select),
24    MultiSelect(MultiSelect),
25    Switch(Switch),
26    DateInput(DateInput),
27    Slider(Slider),
28
29    // Layouts
30    Stack(Stack),
31    Grid(Grid),
32    Card(Card),
33    Container(Container),
34    Divider(Divider),
35    Tabs(Tabs),
36
37    // Data Display
38    Table(Table),
39    List(List),
40    KeyValue(KeyValue),
41    CodeBlock(CodeBlock),
42
43    // Visualizations
44    Chart(Chart),
45    Scene3d(Scene3d),
46
47    // Feedback
48    Alert(Alert),
49    Progress(Progress),
50    Toast(Toast),
51    Modal(Modal),
52    Spinner(Spinner),
53    Skeleton(Skeleton),
54
55    // Extended Inputs
56    Textarea(Textarea),
57}
58
59// --- Atoms ---
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62pub struct Text {
63    /// Optional ID for streaming updates
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub id: Option<String>,
66    pub content: String,
67    #[serde(default)]
68    pub variant: TextVariant,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
72#[serde(rename_all = "snake_case")]
73pub enum TextVariant {
74    H1,
75    H2,
76    H3,
77    H4,
78    #[default]
79    Body,
80    Caption,
81    Code,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct Button {
86    /// Optional ID for streaming updates
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub id: Option<String>,
89    pub label: String,
90    pub action_id: String,
91    #[serde(default)]
92    pub variant: ButtonVariant,
93    #[serde(default)]
94    pub disabled: bool,
95    /// Optional icon name (Lucide icon) to display with the button
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub icon: Option<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
101#[serde(rename_all = "snake_case")]
102pub enum ButtonVariant {
103    #[default]
104    Primary,
105    Secondary,
106    Danger,
107    Ghost,
108    Outline,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
112pub struct Icon {
113    /// Optional ID for streaming updates
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub id: Option<String>,
116    pub name: String, // Lucide icon name
117    #[serde(default)]
118    pub size: u8,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
122pub struct Image {
123    /// Optional ID for streaming updates
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub id: Option<String>,
126    pub src: String,
127    pub alt: Option<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
131pub struct Badge {
132    /// Optional ID for streaming updates
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub id: Option<String>,
135    pub label: String,
136    #[serde(default)]
137    pub variant: BadgeVariant,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
141#[serde(rename_all = "snake_case")]
142pub enum BadgeVariant {
143    #[default]
144    Default,
145    Info,
146    Success,
147    Warning,
148    Error,
149    Secondary,
150    Outline,
151}
152
153// --- Inputs ---
154
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
156pub struct TextInput {
157    /// Optional ID for streaming updates
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub id: Option<String>,
160    pub name: String,
161    pub label: String,
162    /// Input type: text, email, password, tel, url
163    #[serde(default = "default_input_type")]
164    pub input_type: String,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub placeholder: Option<String>,
167    #[serde(default)]
168    pub required: bool,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub default_value: Option<String>,
171    /// Minimum length for text input validation
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub min_length: Option<usize>,
174    /// Maximum length for text input validation
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub max_length: Option<usize>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub error: Option<String>,
179}
180
181fn default_input_type() -> String {
182    "text".to_string()
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
186pub struct NumberInput {
187    /// Optional ID for streaming updates
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub id: Option<String>,
190    pub name: String,
191    pub label: String,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub min: Option<f64>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub max: Option<f64>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub step: Option<f64>,
198    #[serde(default)]
199    pub required: bool,
200    /// Default value for the number input
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub default_value: Option<f64>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub error: Option<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
208pub struct Select {
209    /// Optional ID for streaming updates
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub id: Option<String>,
212    pub name: String,
213    pub label: String,
214    pub options: Vec<SelectOption>,
215    #[serde(default)]
216    pub required: bool,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub error: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
222pub struct MultiSelect {
223    /// Optional ID for streaming updates
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub id: Option<String>,
226    pub name: String,
227    pub label: String,
228    pub options: Vec<SelectOption>,
229    #[serde(default)]
230    pub required: bool,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
234pub struct SelectOption {
235    pub label: String,
236    pub value: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
240pub struct Switch {
241    /// Optional ID for streaming updates
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub id: Option<String>,
244    pub name: String,
245    pub label: String,
246    #[serde(default)]
247    pub default_checked: bool,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
251pub struct DateInput {
252    /// Optional ID for streaming updates
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub id: Option<String>,
255    pub name: String,
256    pub label: String,
257    #[serde(default)]
258    pub required: bool,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
262pub struct Slider {
263    /// Optional ID for streaming updates
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub id: Option<String>,
266    pub name: String,
267    pub label: String,
268    pub min: f64,
269    pub max: f64,
270    pub step: Option<f64>,
271    pub default_value: Option<f64>,
272}
273
274// --- Layouts ---
275
276#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
277pub struct Stack {
278    /// Optional ID for streaming updates
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub id: Option<String>,
281    pub direction: StackDirection,
282    pub children: Vec<Component>,
283    #[serde(default)]
284    pub gap: u8,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
288#[serde(rename_all = "snake_case")]
289pub enum StackDirection {
290    Horizontal,
291    Vertical,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
295pub struct Grid {
296    /// Optional ID for streaming updates
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub id: Option<String>,
299    pub columns: u8,
300    pub children: Vec<Component>,
301    #[serde(default)]
302    pub gap: u8,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
306pub struct Card {
307    /// Optional ID for streaming updates
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub id: Option<String>,
310    pub title: Option<String>,
311    pub description: Option<String>,
312    pub content: Vec<Component>,
313    pub footer: Option<Vec<Component>>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
317pub struct Container {
318    /// Optional ID for streaming updates
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub id: Option<String>,
321    pub children: Vec<Component>,
322    #[serde(default)]
323    pub padding: u8,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327pub struct Divider {
328    /// Optional ID for streaming updates
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub id: Option<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
334pub struct Tabs {
335    /// Optional ID for streaming updates
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub id: Option<String>,
338    pub tabs: Vec<Tab>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
342pub struct Tab {
343    pub label: String,
344    pub content: Vec<Component>,
345}
346
347// --- Data Display ---
348
349/// A data-model binding used instead of literal component data.
350#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
351pub struct DataSource {
352    /// RFC 6901 JSON Pointer into the active surface data model.
353    pub binding_path: String,
354    #[serde(default)]
355    pub refresh: DataRefresh,
356    /// Maximum age, in seconds, before the host should request fresh data.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub ttl: Option<u64>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
362#[serde(tag = "mode", rename_all = "snake_case")]
363pub enum DataRefresh {
364    #[default]
365    Manual,
366    Interval {
367        secs: u64,
368    },
369    Push,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
373pub struct Table {
374    /// Optional ID for streaming updates
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub id: Option<String>,
377    pub columns: Vec<TableColumn>,
378    #[serde(default)]
379    pub data: Vec<HashMap<String, serde_json::Value>>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub data_source: Option<DataSource>,
382    /// Enable sorting on columns
383    #[serde(default)]
384    pub sortable: bool,
385    /// Enable pagination
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub page_size: Option<u32>,
388    /// Striped row styling
389    #[serde(default)]
390    pub striped: bool,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
394pub struct TableColumn {
395    pub header: String,
396    pub accessor_key: String,
397    /// Whether this column is sortable (when table.sortable is true)
398    #[serde(default = "default_true")]
399    pub sortable: bool,
400}
401
402fn default_true() -> bool {
403    true
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
407pub struct List {
408    /// Optional ID for streaming updates
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub id: Option<String>,
411    pub items: Vec<String>,
412    #[serde(default)]
413    pub ordered: bool,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
417pub struct KeyValue {
418    /// Optional ID for streaming updates
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub id: Option<String>,
421    #[serde(default)]
422    pub pairs: Vec<KeyValuePair>,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub data_source: Option<DataSource>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
428pub struct KeyValuePair {
429    pub key: String,
430    pub value: String,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
434pub struct CodeBlock {
435    /// Optional ID for streaming updates
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub id: Option<String>,
438    pub code: String,
439    pub language: Option<String>,
440}
441
442// --- Visualizations ---
443
444#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
445pub struct Chart {
446    /// Optional ID for streaming updates
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub id: Option<String>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub title: Option<String>,
451    pub kind: ChartKind,
452    #[serde(default)]
453    pub data: Vec<HashMap<String, serde_json::Value>>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub data_source: Option<DataSource>,
456    pub x_key: String,
457    pub y_keys: Vec<String>,
458    #[serde(default)]
459    pub x_type: ChartXType,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub time_format: Option<String>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub window: Option<u32>,
464    /// X-axis label
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub x_label: Option<String>,
467    /// Y-axis label
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub y_label: Option<String>,
470    /// Show legend
471    #[serde(default = "default_show_legend")]
472    pub show_legend: bool,
473    /// Custom colors for data series (hex values)
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub colors: Option<Vec<String>>,
476}
477
478fn default_show_legend() -> bool {
479    true
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
483#[serde(rename_all = "snake_case")]
484pub enum ChartKind {
485    Bar,
486    Line,
487    Area,
488    Pie,
489    Scatter,
490    Sparkline,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
494#[serde(rename_all = "snake_case")]
495pub enum ChartXType {
496    #[default]
497    Category,
498    Numeric,
499    Time,
500}
501
502/// Declarative, bounded 3D scene. Hosts retain control of loading, rendering,
503/// interaction, and asset policy; agents cannot inject shaders or executable code.
504#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
505pub struct Scene3d {
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub id: Option<String>,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub title: Option<String>,
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub description: Option<String>,
512    #[serde(default = "default_scene_height")]
513    pub height: u16,
514    #[serde(default)]
515    pub background: SceneBackground,
516    #[serde(default)]
517    pub camera: SceneCamera,
518    pub objects: Vec<SceneObject>,
519    #[serde(default)]
520    pub auto_rotate: bool,
521    #[serde(default = "default_true")]
522    pub controls: bool,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub fallback: Option<String>,
525}
526
527fn default_scene_height() -> u16 {
528    440
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
532#[serde(rename_all = "snake_case")]
533pub enum SceneBackground {
534    Transparent,
535    #[default]
536    Surface,
537    Muted,
538    Contrast,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
542pub struct SceneCamera {
543    #[serde(default = "default_camera_position")]
544    pub position: [f32; 3],
545    #[serde(default = "default_camera_target")]
546    pub target: [f32; 3],
547    #[serde(default = "default_camera_fov")]
548    pub fov: f32,
549}
550
551impl Default for SceneCamera {
552    fn default() -> Self {
553        Self {
554            position: default_camera_position(),
555            target: default_camera_target(),
556            fov: default_camera_fov(),
557        }
558    }
559}
560
561fn default_camera_position() -> [f32; 3] {
562    [4.5, 3.0, 6.0]
563}
564fn default_camera_target() -> [f32; 3] {
565    [0.0, 0.0, 0.0]
566}
567fn default_camera_fov() -> f32 {
568    42.0
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
572#[serde(tag = "kind", rename_all = "snake_case")]
573pub enum SceneObject {
574    Primitive(ScenePrimitive),
575    Model(SceneModel),
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
579pub struct ScenePrimitive {
580    pub id: String,
581    pub shape: ScenePrimitiveShape,
582    #[serde(default)]
583    pub position: [f32; 3],
584    #[serde(default)]
585    pub rotation: [f32; 3],
586    #[serde(default = "default_scene_scale")]
587    pub scale: [f32; 3],
588    #[serde(default)]
589    pub material: SceneMaterialRole,
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub label: Option<String>,
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub action_id: Option<String>,
594}
595
596fn default_scene_scale() -> [f32; 3] {
597    [1.0, 1.0, 1.0]
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
601#[serde(rename_all = "snake_case")]
602pub enum ScenePrimitiveShape {
603    Box,
604    Sphere,
605    Cylinder,
606    Torus,
607    Plane,
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
611#[serde(rename_all = "snake_case")]
612pub enum SceneMaterialRole {
613    #[default]
614    Primary,
615    Secondary,
616    Surface,
617    Muted,
618    Success,
619    Warning,
620    Info,
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
624pub struct SceneModel {
625    pub id: String,
626    /// Approved `model_3d` asset from the active brand kit.
627    pub asset_id: String,
628    #[serde(default)]
629    pub position: [f32; 3],
630    #[serde(default)]
631    pub rotation: [f32; 3],
632    #[serde(default = "default_scene_scale")]
633    pub scale: [f32; 3],
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub label: Option<String>,
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub action_id: Option<String>,
638}
639
640// --- Feedback ---
641
642#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
643pub struct Alert {
644    /// Optional ID for streaming updates
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub id: Option<String>,
647    pub title: String,
648    pub description: Option<String>,
649    #[serde(default)]
650    pub variant: AlertVariant,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
654#[serde(rename_all = "snake_case")]
655pub enum AlertVariant {
656    #[default]
657    Info,
658    Success,
659    Warning,
660    Error,
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
664pub struct Progress {
665    /// Optional ID for streaming updates
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub id: Option<String>,
668    pub value: u8, // 0-100
669    pub label: Option<String>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
673pub struct Toast {
674    #[serde(skip_serializing_if = "Option::is_none")]
675    pub id: Option<String>,
676    pub message: String,
677    #[serde(default)]
678    pub variant: AlertVariant,
679    /// Duration in ms, default 5000
680    #[serde(default = "default_toast_duration")]
681    pub duration: u32,
682    #[serde(default = "default_true")]
683    pub dismissible: bool,
684}
685
686fn default_toast_duration() -> u32 {
687    5000
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
691pub struct Modal {
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub id: Option<String>,
694    pub title: String,
695    pub content: Vec<Component>,
696    pub footer: Option<Vec<Component>>,
697    #[serde(default)]
698    pub size: ModalSize,
699    #[serde(default = "default_true")]
700    pub closable: bool,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
704#[serde(rename_all = "snake_case")]
705pub enum ModalSize {
706    Small,
707    #[default]
708    Medium,
709    Large,
710    Full,
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
714pub struct Spinner {
715    #[serde(skip_serializing_if = "Option::is_none")]
716    pub id: Option<String>,
717    #[serde(default)]
718    pub size: SpinnerSize,
719    pub label: Option<String>,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
723#[serde(rename_all = "snake_case")]
724pub enum SpinnerSize {
725    Small,
726    #[default]
727    Medium,
728    Large,
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
732pub struct Skeleton {
733    #[serde(skip_serializing_if = "Option::is_none")]
734    pub id: Option<String>,
735    #[serde(default)]
736    pub variant: SkeletonVariant,
737    pub width: Option<String>,
738    pub height: Option<String>,
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
742#[serde(rename_all = "snake_case")]
743pub enum SkeletonVariant {
744    #[default]
745    Text,
746    Circle,
747    Rectangle,
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
751pub struct Textarea {
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub id: Option<String>,
754    pub name: String,
755    pub label: String,
756    pub placeholder: Option<String>,
757    #[serde(default = "default_textarea_rows")]
758    pub rows: u8,
759    #[serde(default)]
760    pub required: bool,
761    pub default_value: Option<String>,
762    #[serde(skip_serializing_if = "Option::is_none")]
763    pub error: Option<String>,
764}
765
766fn default_textarea_rows() -> u8 {
767    4
768}
769
770// --- Root Response ---
771
772/// Theme configuration for UI rendering
773#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
774#[serde(rename_all = "snake_case")]
775pub enum Theme {
776    #[default]
777    Light,
778    Dark,
779    System,
780}
781
782#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
783pub struct UiResponse {
784    /// Unique ID for this UI response (for updates)
785    #[serde(default)]
786    pub id: Option<String>,
787    /// Approved brand-kit/catalog identifier used to constrain and style this response.
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub kit_id: Option<String>,
790    /// Theme preference
791    #[serde(default)]
792    pub theme: Theme,
793    /// Components to render
794    pub components: Vec<Component>,
795}
796
797impl UiResponse {
798    pub fn new(components: Vec<Component>) -> Self {
799        Self {
800            id: None,
801            kit_id: None,
802            theme: Theme::default(),
803            components,
804        }
805    }
806
807    pub fn with_theme(mut self, theme: Theme) -> Self {
808        self.theme = theme;
809        self
810    }
811
812    pub fn with_id(mut self, id: impl Into<String>) -> Self {
813        self.id = Some(id.into());
814        self
815    }
816
817    pub fn with_kit_id(mut self, kit_id: impl Into<String>) -> Self {
818        self.kit_id = Some(kit_id.into());
819        self
820    }
821
822    pub fn to_content(self) -> crate::compat::Content {
823        let json = serde_json::to_vec(&self).unwrap_or_default();
824        crate::compat::Content {
825            role: "model".to_string(),
826            parts: vec![crate::compat::inline_data_part(MIME_TYPE_UI, json)],
827        }
828    }
829}
830
831// --- User Events (UI → Agent) ---
832
833/// Event sent from UI to agent when user interacts with components
834#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
835#[serde(tag = "action", rename_all = "snake_case")]
836pub enum UiEvent {
837    /// Form submission with collected field values
838    FormSubmit {
839        /// The action_id from the submit button
840        action_id: String,
841        /// Form field values as key-value pairs
842        data: HashMap<String, serde_json::Value>,
843    },
844    /// Button click (non-form)
845    ButtonClick {
846        /// The action_id from the button
847        action_id: String,
848    },
849    /// Value changed in an input field
850    InputChange {
851        /// Field name
852        name: String,
853        /// New value
854        value: serde_json::Value,
855    },
856    /// Tab navigation
857    TabChange {
858        /// Tab index selected
859        index: usize,
860    },
861}
862
863/// Typed UI-to-agent event with surface correlation metadata.
864#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
865pub struct UiEventEnvelope {
866    pub surface_ref: SurfaceRef,
867    pub event: UiEvent,
868    pub occurred_at: String,
869    #[serde(default, skip_serializing_if = "Option::is_none")]
870    pub metadata: Option<serde_json::Value>,
871}
872
873impl UiEventEnvelope {
874    pub fn new(surface_ref: SurfaceRef, event: UiEvent) -> Self {
875        Self {
876            surface_ref,
877            event,
878            occurred_at: chrono::Utc::now().to_rfc3339(),
879            metadata: None,
880        }
881    }
882
883    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
884        self.metadata = Some(metadata);
885        self
886    }
887
888    pub fn to_content(&self) -> crate::compat::Content {
889        let json = serde_json::to_vec(self).unwrap_or_default();
890        crate::compat::Content {
891            role: "user".to_string(),
892            parts: vec![crate::compat::inline_data_part(MIME_TYPE_UI_EVENT, json)],
893        }
894    }
895}
896
897impl UiEvent {
898    /// Convert UI event to a user message for the agent
899    pub fn to_user_message(&self) -> String {
900        match self {
901            UiEvent::FormSubmit { action_id, data } => {
902                let json = serde_json::to_string_pretty(data).unwrap_or_default();
903                format!(
904                    "[UI Event: Form submitted]\nAction: {}\nData:\n{}",
905                    action_id, json
906                )
907            }
908            UiEvent::ButtonClick { action_id } => {
909                format!("[UI Event: Button clicked]\nAction: {}", action_id)
910            }
911            UiEvent::InputChange { name, value } => {
912                format!(
913                    "[UI Event: Input changed]\nField: {}\nValue: {}",
914                    name, value
915                )
916            }
917            UiEvent::TabChange { index } => {
918                format!("[UI Event: Tab changed]\nIndex: {}", index)
919            }
920        }
921    }
922
923    /// Convert to typed Content for sending to an agent runtime.
924    pub fn to_content(&self) -> crate::compat::Content {
925        let json = serde_json::to_vec(self).unwrap_or_default();
926        crate::compat::Content {
927            role: "user".to_string(),
928            parts: vec![crate::compat::inline_data_part(MIME_TYPE_UI_EVENT, json)],
929        }
930    }
931}
932
933// --- Streaming Updates (Agent → UI) ---
934
935/// Operation type for streaming UI updates
936#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
937#[serde(rename_all = "snake_case")]
938pub enum UiOperation {
939    /// Replace entire component
940    Replace,
941    /// Merge with existing component data
942    Patch,
943    /// Append children to a container
944    Append,
945    /// Remove the component
946    Remove,
947}
948
949/// Incremental UI update for streaming
950///
951/// Allows agents to update specific components by ID without
952/// re-rendering the entire UI.
953#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
954pub struct UiUpdate {
955    /// Target component ID to update
956    pub target_id: String,
957    /// Operation to perform
958    pub operation: UiOperation,
959    /// Payload data (component for replace/patch/append, None for remove)
960    #[serde(skip_serializing_if = "Option::is_none")]
961    pub payload: Option<Component>,
962}
963
964impl UiUpdate {
965    /// Create a replace update
966    pub fn replace(target_id: impl Into<String>, component: Component) -> Self {
967        Self {
968            target_id: target_id.into(),
969            operation: UiOperation::Replace,
970            payload: Some(component),
971        }
972    }
973
974    /// Create a patch update that merges component fields into the target.
975    pub fn patch(target_id: impl Into<String>, component: Component) -> Self {
976        Self {
977            target_id: target_id.into(),
978            operation: UiOperation::Patch,
979            payload: Some(component),
980        }
981    }
982
983    /// Create a remove update
984    pub fn remove(target_id: impl Into<String>) -> Self {
985        Self {
986            target_id: target_id.into(),
987            operation: UiOperation::Remove,
988            payload: None,
989        }
990    }
991
992    /// Create an append update (for containers)
993    pub fn append(target_id: impl Into<String>, component: Component) -> Self {
994        Self {
995            target_id: target_id.into(),
996            operation: UiOperation::Append,
997            payload: Some(component),
998        }
999    }
1000
1001    /// Convert to Content for sending via Events
1002    pub fn to_content(self) -> crate::compat::Content {
1003        let json = serde_json::to_vec(&self).unwrap_or_default();
1004        crate::compat::Content {
1005            role: "model".to_string(),
1006            parts: vec![crate::compat::inline_data_part(MIME_TYPE_UI_UPDATE, json)],
1007        }
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014
1015    #[test]
1016    fn test_component_serialization_roundtrip() {
1017        let text = Component::Text(Text {
1018            id: Some("text-1".to_string()),
1019            content: "Hello".to_string(),
1020            variant: TextVariant::Body,
1021        });
1022
1023        let json = serde_json::to_string(&text).unwrap();
1024        let deserialized: Component = serde_json::from_str(&json).unwrap();
1025
1026        if let Component::Text(t) = deserialized {
1027            assert_eq!(t.content, "Hello");
1028            assert_eq!(t.id, Some("text-1".to_string()));
1029        } else {
1030            panic!("Expected Text component");
1031        }
1032    }
1033
1034    #[test]
1035    fn test_ui_response_with_id() {
1036        let ui = UiResponse::new(vec![])
1037            .with_id("response-123")
1038            .with_theme(Theme::Dark);
1039
1040        assert_eq!(ui.id, Some("response-123".to_string()));
1041        assert!(matches!(ui.theme, Theme::Dark));
1042    }
1043
1044    #[test]
1045    fn test_badge_variants_serialize() {
1046        let badge = Badge {
1047            id: None,
1048            label: "Test".to_string(),
1049            variant: BadgeVariant::Success,
1050        };
1051        let json = serde_json::to_string(&badge).unwrap();
1052        assert!(json.contains("success"));
1053    }
1054
1055    #[test]
1056    fn test_ui_event_to_message() {
1057        let event = UiEvent::FormSubmit {
1058            action_id: "submit".to_string(),
1059            data: HashMap::new(),
1060        };
1061        let msg = event.to_user_message();
1062        assert!(msg.contains("Form submitted"));
1063        assert!(msg.contains("submit"));
1064    }
1065
1066    #[test]
1067    fn ui_event_content_is_typed() {
1068        let event = UiEvent::ButtonClick {
1069            action_id: "open".to_string(),
1070        };
1071        let content = event.to_content();
1072        let value = serde_json::to_value(content).unwrap();
1073        assert_eq!(value["parts"][0]["mime_type"], MIME_TYPE_UI_EVENT);
1074    }
1075
1076    #[test]
1077    fn patch_constructor_emits_patch_operation() {
1078        let update = UiUpdate::patch(
1079            "status",
1080            Component::Text(Text {
1081                id: Some("status".to_string()),
1082                content: "ready".to_string(),
1083                variant: TextVariant::Body,
1084            }),
1085        );
1086        assert!(matches!(update.operation, UiOperation::Patch));
1087    }
1088
1089    #[test]
1090    fn test_ui_update_replace() {
1091        let update = UiUpdate::replace(
1092            "target-1",
1093            Component::Text(Text {
1094                id: None,
1095                content: "Updated".to_string(),
1096                variant: TextVariant::Body,
1097            }),
1098        );
1099
1100        assert_eq!(update.target_id, "target-1");
1101        assert!(matches!(update.operation, UiOperation::Replace));
1102        assert!(update.payload.is_some());
1103    }
1104
1105    #[test]
1106    fn test_ui_update_remove() {
1107        let update = UiUpdate::remove("to-delete");
1108        assert_eq!(update.target_id, "to-delete");
1109        assert!(matches!(update.operation, UiOperation::Remove));
1110        assert!(update.payload.is_none());
1111    }
1112
1113    #[test]
1114    fn test_key_value_pairs() {
1115        let kv = KeyValue {
1116            id: Some("kv-1".to_string()),
1117            pairs: vec![
1118                KeyValuePair {
1119                    key: "Name".to_string(),
1120                    value: "Alice".to_string(),
1121                },
1122                KeyValuePair {
1123                    key: "Age".to_string(),
1124                    value: "30".to_string(),
1125                },
1126            ],
1127            data_source: None,
1128        };
1129
1130        let json = serde_json::to_string(&kv).unwrap();
1131        assert!(json.contains("pairs"));
1132        assert!(json.contains("Alice"));
1133    }
1134
1135    #[test]
1136    fn test_component_with_id_skips_none() {
1137        let text = Component::Text(Text {
1138            id: None,
1139            content: "No ID".to_string(),
1140            variant: TextVariant::Body,
1141        });
1142
1143        let json = serde_json::to_string(&text).unwrap();
1144        // id should not be present when None due to skip_serializing_if
1145        assert!(!json.contains("\"id\""));
1146    }
1147}