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 { id: None, theme: Theme::default(), components }
607    }
608
609    pub fn with_theme(mut self, theme: Theme) -> Self {
610        self.theme = theme;
611        self
612    }
613
614    pub fn with_id(mut self, id: impl Into<String>) -> Self {
615        self.id = Some(id.into());
616        self
617    }
618
619    pub fn to_content(self) -> adk_core::Content {
620        let json = serde_json::to_vec(&self).unwrap_or_default();
621        adk_core::Content {
622            role: "model".to_string(),
623            parts: vec![adk_core::Part::InlineData {
624                mime_type: MIME_TYPE_UI.to_string(),
625                data: json,
626            }],
627        }
628    }
629}
630
631// --- User Events (UI → Agent) ---
632
633/// Event sent from UI to agent when user interacts with components
634#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
635#[serde(tag = "action", rename_all = "snake_case")]
636pub enum UiEvent {
637    /// Form submission with collected field values
638    FormSubmit {
639        /// The action_id from the submit button
640        action_id: String,
641        /// Form field values as key-value pairs
642        data: HashMap<String, serde_json::Value>,
643    },
644    /// Button click (non-form)
645    ButtonClick {
646        /// The action_id from the button
647        action_id: String,
648    },
649    /// Value changed in an input field
650    InputChange {
651        /// Field name
652        name: String,
653        /// New value
654        value: serde_json::Value,
655    },
656    /// Tab navigation
657    TabChange {
658        /// Tab index selected
659        index: usize,
660    },
661}
662
663impl UiEvent {
664    /// Convert UI event to a user message for the agent
665    pub fn to_user_message(&self) -> String {
666        match self {
667            UiEvent::FormSubmit { action_id, data } => {
668                let json = serde_json::to_string_pretty(data).unwrap_or_default();
669                format!("[UI Event: Form submitted]\nAction: {}\nData:\n{}", action_id, json)
670            }
671            UiEvent::ButtonClick { action_id } => {
672                format!("[UI Event: Button clicked]\nAction: {}", action_id)
673            }
674            UiEvent::InputChange { name, value } => {
675                format!("[UI Event: Input changed]\nField: {}\nValue: {}", name, value)
676            }
677            UiEvent::TabChange { index } => {
678                format!("[UI Event: Tab changed]\nIndex: {}", index)
679            }
680        }
681    }
682
683    /// Convert to Content for sending to agent
684    pub fn to_content(&self) -> adk_core::Content {
685        adk_core::Content::new("user").with_text(self.to_user_message())
686    }
687}
688
689// --- Streaming Updates (Agent → UI) ---
690
691/// Operation type for streaming UI updates
692#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
693#[serde(rename_all = "snake_case")]
694pub enum UiOperation {
695    /// Replace entire component
696    Replace,
697    /// Merge with existing component data
698    Patch,
699    /// Append children to a container
700    Append,
701    /// Remove the component
702    Remove,
703}
704
705/// Incremental UI update for streaming
706///
707/// Allows agents to update specific components by ID without
708/// re-rendering the entire UI.
709#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
710pub struct UiUpdate {
711    /// Target component ID to update
712    pub target_id: String,
713    /// Operation to perform
714    pub operation: UiOperation,
715    /// Payload data (component for replace/patch/append, None for remove)
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub payload: Option<Component>,
718}
719
720impl UiUpdate {
721    /// Create a replace update
722    pub fn replace(target_id: impl Into<String>, component: Component) -> Self {
723        Self {
724            target_id: target_id.into(),
725            operation: UiOperation::Replace,
726            payload: Some(component),
727        }
728    }
729
730    /// Create a remove update
731    pub fn remove(target_id: impl Into<String>) -> Self {
732        Self { target_id: target_id.into(), operation: UiOperation::Remove, payload: None }
733    }
734
735    /// Create an append update (for containers)
736    pub fn append(target_id: impl Into<String>, component: Component) -> Self {
737        Self {
738            target_id: target_id.into(),
739            operation: UiOperation::Append,
740            payload: Some(component),
741        }
742    }
743
744    /// Convert to Content for sending via Events
745    pub fn to_content(self) -> adk_core::Content {
746        let json = serde_json::to_vec(&self).unwrap_or_default();
747        adk_core::Content {
748            role: "model".to_string(),
749            parts: vec![adk_core::Part::InlineData {
750                mime_type: MIME_TYPE_UI_UPDATE.to_string(),
751                data: json,
752            }],
753        }
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn test_component_serialization_roundtrip() {
763        let text = Component::Text(Text {
764            id: Some("text-1".to_string()),
765            content: "Hello".to_string(),
766            variant: TextVariant::Body,
767        });
768
769        let json = serde_json::to_string(&text).unwrap();
770        let deserialized: Component = serde_json::from_str(&json).unwrap();
771
772        if let Component::Text(t) = deserialized {
773            assert_eq!(t.content, "Hello");
774            assert_eq!(t.id, Some("text-1".to_string()));
775        } else {
776            panic!("Expected Text component");
777        }
778    }
779
780    #[test]
781    fn test_ui_response_with_id() {
782        let ui = UiResponse::new(vec![]).with_id("response-123").with_theme(Theme::Dark);
783
784        assert_eq!(ui.id, Some("response-123".to_string()));
785        assert!(matches!(ui.theme, Theme::Dark));
786    }
787
788    #[test]
789    fn test_badge_variants_serialize() {
790        let badge = Badge { id: None, label: "Test".to_string(), variant: BadgeVariant::Success };
791        let json = serde_json::to_string(&badge).unwrap();
792        assert!(json.contains("success"));
793    }
794
795    #[test]
796    fn test_ui_event_to_message() {
797        let event = UiEvent::FormSubmit { action_id: "submit".to_string(), data: HashMap::new() };
798        let msg = event.to_user_message();
799        assert!(msg.contains("Form submitted"));
800        assert!(msg.contains("submit"));
801    }
802
803    #[test]
804    fn test_ui_update_replace() {
805        let update = UiUpdate::replace(
806            "target-1",
807            Component::Text(Text {
808                id: None,
809                content: "Updated".to_string(),
810                variant: TextVariant::Body,
811            }),
812        );
813
814        assert_eq!(update.target_id, "target-1");
815        assert!(matches!(update.operation, UiOperation::Replace));
816        assert!(update.payload.is_some());
817    }
818
819    #[test]
820    fn test_ui_update_remove() {
821        let update = UiUpdate::remove("to-delete");
822        assert_eq!(update.target_id, "to-delete");
823        assert!(matches!(update.operation, UiOperation::Remove));
824        assert!(update.payload.is_none());
825    }
826
827    #[test]
828    fn test_key_value_pairs() {
829        let kv = KeyValue {
830            id: Some("kv-1".to_string()),
831            pairs: vec![
832                KeyValuePair { key: "Name".to_string(), value: "Alice".to_string() },
833                KeyValuePair { key: "Age".to_string(), value: "30".to_string() },
834            ],
835        };
836
837        let json = serde_json::to_string(&kv).unwrap();
838        assert!(json.contains("pairs"));
839        assert!(json.contains("Alice"));
840    }
841
842    #[test]
843    fn test_component_with_id_skips_none() {
844        let text = Component::Text(Text {
845            id: None,
846            content: "No ID".to_string(),
847            variant: TextVariant::Body,
848        });
849
850        let json = serde_json::to_string(&text).unwrap();
851        // id should not be present when None due to skip_serializing_if
852        assert!(!json.contains("\"id\""));
853    }
854}