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