Skip to main content

adk_ui/
schema.rs

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