Skip to main content

adk_ui/tools/
render_layout.rs

1use crate::a2ui::stable_id;
2use crate::compat::{Result, Tool, ToolContext};
3use crate::schema::*;
4use crate::tools::render_form::{FormField, build_form_content};
5use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
6use async_trait::async_trait;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13/// A section in a dashboard layout.
14///
15/// Each section has a `type` field that determines which other fields are used:
16/// - `"stats"`: Uses `stats` field for label/value/status items
17/// - `"text"`: Uses `text` field for plain text content
18/// - `"alert"`: Uses `message` and `severity` fields
19/// - `"table"`: Uses `columns` and `rows` fields
20/// - `"chart"`: Uses `chart_type`, `data`, `x_key`, `y_keys` fields
21/// - `"key_value"`: Uses `pairs` field for key-value display
22/// - `"list"`: Uses `items` and `ordered` fields
23/// - `"code_block"`: Uses `code` and `language` fields
24/// - `"form"`: Uses `fields`, `submit_action`, and `submit_label` fields
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26pub struct DashboardSection {
27    /// Section title displayed as card header
28    pub title: String,
29    /// Type of content: "stats", "table", "chart", "alert", "text", "key_value", "list", "code_block", "form"
30    #[serde(rename = "type")]
31    pub section_type: String,
32    /// For stats sections: list of label/value pairs with optional status
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub stats: Option<Vec<StatItem>>,
35    /// For text sections: the text content
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub text: Option<String>,
38    /// For alert sections: the message to display
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub message: Option<String>,
41    /// For alert sections: severity level ("info", "success", "warning", "error")
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub severity: Option<String>,
44    /// For table sections: column definitions
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub columns: Option<Vec<ColumnSpec>>,
47    /// For table sections: row data as key-value maps
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub rows: Option<Vec<HashMap<String, Value>>>,
50    /// For chart sections: chart type ("bar", "line", "area", "pie")
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub chart_type: Option<String>,
53    /// For chart sections: data points as key-value maps
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub data: Option<Vec<HashMap<String, Value>>>,
56    /// For chart sections: key for x-axis values
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub x_key: Option<String>,
59    /// For chart sections: keys for y-axis values
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub y_keys: Option<Vec<String>>,
62    /// For key_value sections: list of key-value pairs
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub pairs: Option<Vec<KeyValueItem>>,
65    /// For list sections: list of text items
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub items: Option<Vec<String>>,
68    /// For list sections: whether to display as ordered list (default: false)
69    #[serde(default)]
70    pub ordered: bool,
71    /// For code_block sections: the code content
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub code: Option<String>,
74    /// For code_block sections: programming language for syntax highlighting
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub language: Option<String>,
77    /// For form sections: fields to collect without giving up the surrounding decision context
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub fields: Option<Vec<FormField>>,
80    /// For form sections: action id emitted when the form is submitted
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub submit_action: Option<String>,
83    /// For form sections: specific review-oriented button label
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub submit_label: Option<String>,
86    /// For form sections: optional data binding path prefix
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub data_path_prefix: Option<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct StatItem {
93    /// Label displayed for this stat
94    pub label: String,
95    /// Value displayed for this stat
96    pub value: String,
97    /// Optional status indicator: "operational"/"ok"/"success" (green), "degraded"/"warning" (yellow), "down"/"error"/"outage" (red)
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub status: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
103pub struct ColumnSpec {
104    /// Column header text
105    pub header: String,
106    /// Key to access data from row objects
107    pub key: String,
108}
109
110/// Key-value pair for key_value sections
111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
112pub struct KeyValueItem {
113    /// Display label
114    pub key: String,
115    /// Display value
116    pub value: String,
117}
118
119/// Parameters for the render_layout tool
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
121pub struct RenderLayoutParams {
122    /// Dashboard/layout title
123    pub title: String,
124    /// Optional description
125    #[serde(default)]
126    pub description: Option<String>,
127    /// Sections to display
128    pub sections: Vec<DashboardSection>,
129    /// Theme: "light", "dark", or "system" (default: "light")
130    #[serde(default)]
131    pub theme: Option<String>,
132    /// Optional protocol output configuration.
133    #[serde(flatten)]
134    pub protocol: LegacyProtocolOptions,
135}
136
137/// Tool for rendering complex multi-component layouts.
138///
139/// Creates dashboard-style layouts with multiple sections, each containing
140/// different types of content. Ideal for status pages, admin dashboards,
141/// and multi-section displays.
142///
143/// # Supported Section Types
144///
145/// - `stats`: Status indicators with labels, values, and optional status colors
146/// - `text`: Plain text content
147/// - `alert`: Notification banners with severity levels
148/// - `table`: Tabular data with columns and rows
149/// - `chart`: Data visualizations (bar, line, area, pie)
150/// - `key_value`: Key-value pair displays
151/// - `list`: Ordered or unordered lists
152/// - `code_block`: Code snippets with syntax highlighting
153/// - `form`: Editable proposal or focused controls inside the wider layout
154///
155/// # Example JSON Parameters
156///
157/// ```json
158/// {
159///   "title": "System Status",
160///   "sections": [
161///     {
162///       "title": "Services",
163///       "type": "stats",
164///       "stats": [
165///         { "label": "API", "value": "Healthy", "status": "operational" },
166///         { "label": "Database", "value": "Degraded", "status": "warning" }
167///       ]
168///     },
169///     {
170///       "title": "Configuration",
171///       "type": "key_value",
172///       "pairs": [
173///         { "key": "Version", "value": "1.2.3" },
174///         { "key": "Region", "value": "us-east-1" }
175///       ]
176///     }
177///   ]
178/// }
179/// ```
180pub struct RenderLayoutTool;
181
182impl RenderLayoutTool {
183    pub fn new() -> Self {
184        Self
185    }
186}
187
188impl Default for RenderLayoutTool {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194#[async_trait]
195impl Tool for RenderLayoutTool {
196    fn name(&self) -> &str {
197        "render_layout"
198    }
199
200    fn description(&self) -> &str {
201        r#"Render a dashboard layout with multiple sections. Output example:
202┌─────────────────────────────────────────────┐
203│ System Status                               │
204├─────────────────────────────────────────────┤
205│ CPU: 45% ✓  │ Memory: 78% ⚠  │ Disk: 92% ✗ │
206├─────────────────────────────────────────────┤
207│ [Chart: Usage over time]                    │
208├─────────────────────────────────────────────┤
209│ Region: us-east-1  │  Version: 1.2.3        │
210└─────────────────────────────────────────────┘
211Section types: stats (label/value/status), table, chart, alert, text, key_value, list, code_block, form. Use a form section for editable filters or proposals that need surrounding dashboard context."#
212    }
213
214    fn parameters_schema(&self) -> Option<Value> {
215        Some(super::generate_gemini_schema::<RenderLayoutParams>())
216    }
217
218    async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
219        let params: RenderLayoutParams = serde_json::from_value(args.clone()).map_err(|e| {
220            crate::compat::AdkError::tool(format!("Invalid parameters: {}. Got: {}", e, args))
221        })?;
222        let protocol_options = params.protocol.clone();
223
224        let mut components = Vec::new();
225
226        // Title
227        components.push(Component::Text(Text {
228            id: None,
229            content: params.title,
230            variant: TextVariant::H2,
231        }));
232
233        // Description
234        if let Some(desc) = params.description {
235            components.push(Component::Text(Text {
236                id: None,
237                content: desc,
238                variant: TextVariant::Caption,
239            }));
240        }
241
242        // Build sections
243        for section in params.sections {
244            let section_component = build_section_component(section);
245            components.push(section_component);
246        }
247
248        let mut ui = UiResponse::new(components);
249
250        // Apply theme if specified
251        if let Some(theme_str) = params.theme {
252            let theme = match theme_str.to_lowercase().as_str() {
253                "dark" => Theme::Dark,
254                "system" => Theme::System,
255                _ => Theme::Light,
256            };
257            ui = ui.with_theme(theme);
258        }
259
260        let surface_id = protocol_options.resolved_surface_id("layout");
261        let output = render_ui_response_with_protocol(ui, &protocol_options, "layout")?;
262        let surface_ref = crate::surface_runtime::next_surface_ref(&ctx, surface_id);
263        crate::surface_runtime::record_surface_ref(&ctx, &surface_ref);
264        Ok(output)
265    }
266}
267
268fn build_section_component(section: DashboardSection) -> Component {
269    let mut card_content: Vec<Component> = Vec::new();
270
271    match section.section_type.as_str() {
272        "stats" => {
273            if let Some(stats) = section.stats {
274                return build_metric_section(section.title, stats);
275            }
276        }
277        "text" => {
278            if let Some(text) = section.text {
279                card_content.push(Component::Text(Text {
280                    id: None,
281                    content: text,
282                    variant: TextVariant::Body,
283                }));
284            }
285        }
286        "alert" => {
287            let variant = match section.severity.as_deref() {
288                Some("success") => AlertVariant::Success,
289                Some("warning") => AlertVariant::Warning,
290                Some("error") => AlertVariant::Error,
291                _ => AlertVariant::Info,
292            };
293            return Component::Alert(Alert {
294                id: None,
295                title: section.title,
296                description: section.message,
297                variant,
298            });
299        }
300        "table" => {
301            if let (Some(cols), Some(rows)) = (section.columns, section.rows) {
302                let page_size = (rows.len() > 10).then_some(10);
303                let table_columns: Vec<TableColumn> = cols
304                    .into_iter()
305                    .map(|c| TableColumn {
306                        header: c.header,
307                        accessor_key: c.key,
308                        sortable: true,
309                    })
310                    .collect();
311                card_content.push(Component::Table(Table {
312                    id: None,
313                    columns: table_columns,
314                    data: rows,
315                    data_source: None,
316                    sortable: true,
317                    page_size,
318                    striped: true,
319                }));
320            }
321        }
322        "chart" => {
323            if let (Some(data), Some(x), Some(y)) = (section.data, section.x_key, section.y_keys) {
324                let kind = match section.chart_type.as_deref() {
325                    Some("line") => ChartKind::Line,
326                    Some("area") => ChartKind::Area,
327                    Some("pie") => ChartKind::Pie,
328                    _ => ChartKind::Bar,
329                };
330                card_content.push(Component::Chart(Chart {
331                    id: None,
332                    title: None,
333                    kind,
334                    data,
335                    data_source: None,
336                    x_key: x,
337                    y_keys: y,
338                    x_type: ChartXType::Category,
339                    time_format: None,
340                    window: None,
341                    x_label: None,
342                    y_label: None,
343                    show_legend: true,
344                    colors: None,
345                }));
346            }
347        }
348        "key_value" => {
349            if let Some(pairs) = section.pairs {
350                let normalized_title = section.title.to_ascii_lowercase();
351                let represents_metrics = normalized_title.contains("kpi")
352                    || normalized_title.contains("key performance")
353                    || normalized_title.contains("metric");
354                if represents_metrics && (2..=8).contains(&pairs.len()) {
355                    let stats = pairs
356                        .into_iter()
357                        .map(|pair| StatItem {
358                            label: pair.key,
359                            value: pair.value,
360                            status: None,
361                        })
362                        .collect();
363                    return build_metric_section(section.title, stats);
364                }
365
366                let kv_pairs: Vec<KeyValuePair> = pairs
367                    .into_iter()
368                    .map(|p| KeyValuePair {
369                        key: p.key,
370                        value: p.value,
371                    })
372                    .collect();
373                card_content.push(Component::KeyValue(KeyValue {
374                    id: None,
375                    pairs: kv_pairs,
376                    data_source: None,
377                }));
378            }
379        }
380        "list" => {
381            if let Some(items) = section.items {
382                card_content.push(Component::List(List {
383                    id: None,
384                    items,
385                    ordered: section.ordered,
386                }));
387            }
388        }
389        "code_block" => {
390            if let Some(code) = section.code {
391                card_content.push(Component::CodeBlock(CodeBlock {
392                    id: None,
393                    code,
394                    language: section.language,
395                }));
396            }
397        }
398        "form" => {
399            if let Some(fields) = section.fields {
400                let form_id = stable_id(&format!("layout-form:{}", section.title));
401                let submit_action = section
402                    .submit_action
403                    .as_deref()
404                    .unwrap_or("review_proposal");
405                let submit_label = section.submit_label.as_deref().unwrap_or("Review proposal");
406                card_content.extend(build_form_content(
407                    &form_id,
408                    fields,
409                    section.data_path_prefix.as_deref(),
410                    submit_action,
411                    submit_label,
412                ));
413            }
414        }
415        _ => {
416            // Fallback: show raw text for unknown section types
417            card_content.push(Component::Text(Text {
418                id: None,
419                content: format!("Unknown section type: {}", section.section_type),
420                variant: TextVariant::Caption,
421            }));
422        }
423    }
424
425    // If no content was added, add a placeholder
426    if card_content.is_empty() {
427        card_content.push(Component::Text(Text {
428            id: None,
429            content: "(No content)".to_string(),
430            variant: TextVariant::Caption,
431        }));
432    }
433
434    Component::Card(Card {
435        id: None,
436        title: Some(section.title),
437        description: None,
438        content: card_content,
439        footer: None,
440    })
441}
442
443fn build_metric_section(title: String, stats: Vec<StatItem>) -> Component {
444    let columns = stats.len().clamp(1, 4) as u8;
445    let metric_cards = stats
446        .into_iter()
447        .map(|stat| {
448            let mut content = vec![Component::Text(Text {
449                id: None,
450                content: stat.value,
451                variant: TextVariant::H2,
452            })];
453            if let Some(status) = stat.status {
454                let variant = match status.as_str() {
455                    "operational" | "ok" | "success" => BadgeVariant::Success,
456                    "degraded" | "warning" => BadgeVariant::Warning,
457                    "down" | "error" | "outage" => BadgeVariant::Error,
458                    _ => BadgeVariant::Secondary,
459                };
460                content.push(Component::Badge(Badge {
461                    id: None,
462                    label: status.replace(['_', '-'], " "),
463                    variant,
464                }));
465            }
466            Component::Card(Card {
467                id: None,
468                title: Some(stat.label),
469                description: None,
470                content,
471                footer: None,
472            })
473        })
474        .collect();
475
476    Component::Stack(Stack {
477        id: None,
478        direction: StackDirection::Vertical,
479        gap: 3,
480        children: vec![
481            Component::Text(Text {
482                id: None,
483                content: title,
484                variant: TextVariant::H3,
485            }),
486            Component::Grid(Grid {
487                id: None,
488                columns,
489                children: metric_cards,
490                gap: 3,
491            }),
492        ],
493    })
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn stats_render_as_responsive_metric_cards() {
502        let component = build_section_component(DashboardSection {
503            title: "System health".to_string(),
504            section_type: "stats".to_string(),
505            stats: Some(vec![
506                StatItem {
507                    label: "Revenue".to_string(),
508                    value: "$42k".to_string(),
509                    status: Some("success".to_string()),
510                },
511                StatItem {
512                    label: "Incidents".to_string(),
513                    value: "3".to_string(),
514                    status: Some("warning".to_string()),
515                },
516            ]),
517            text: None,
518            message: None,
519            severity: None,
520            columns: None,
521            rows: None,
522            chart_type: None,
523            data: None,
524            x_key: None,
525            y_keys: None,
526            pairs: None,
527            items: None,
528            ordered: false,
529            code: None,
530            language: None,
531            fields: None,
532            submit_action: None,
533            submit_label: None,
534            data_path_prefix: None,
535        });
536
537        let Component::Stack(stack) = component else {
538            panic!("stats section should render as a stack");
539        };
540        let Component::Grid(grid) = &stack.children[1] else {
541            panic!("stats stack should contain a grid");
542        };
543        assert_eq!(grid.columns, 2);
544        assert_eq!(grid.children.len(), 2);
545    }
546
547    #[test]
548    fn kpi_key_values_are_promoted_to_metric_cards() {
549        let component = build_section_component(DashboardSection {
550            title: "Key Performance Indicators".to_string(),
551            section_type: "key_value".to_string(),
552            stats: None,
553            text: None,
554            message: None,
555            severity: None,
556            columns: None,
557            rows: None,
558            chart_type: None,
559            data: None,
560            x_key: None,
561            y_keys: None,
562            pairs: Some(vec![
563                KeyValueItem {
564                    key: "Revenue".to_string(),
565                    value: "$42k".to_string(),
566                },
567                KeyValueItem {
568                    key: "Users".to_string(),
569                    value: "1,250".to_string(),
570                },
571                KeyValueItem {
572                    key: "Conversion".to_string(),
573                    value: "12%".to_string(),
574                },
575            ]),
576            items: None,
577            ordered: false,
578            code: None,
579            language: None,
580            fields: None,
581            submit_action: None,
582            submit_label: None,
583            data_path_prefix: None,
584        });
585
586        let Component::Stack(stack) = component else {
587            panic!("KPI key/value section should render as a metric stack");
588        };
589        let Component::Grid(grid) = &stack.children[1] else {
590            panic!("KPI metric stack should contain a grid");
591        };
592        assert_eq!(grid.columns, 3);
593        assert_eq!(grid.children.len(), 3);
594    }
595
596    #[test]
597    fn form_section_keeps_editable_controls_in_layout() {
598        let component = build_section_component(DashboardSection {
599            title: "Rollback proposal".to_string(),
600            section_type: "form".to_string(),
601            stats: None,
602            text: None,
603            message: None,
604            severity: None,
605            columns: None,
606            rows: None,
607            chart_type: None,
608            data: None,
609            x_key: None,
610            y_keys: None,
611            pairs: None,
612            items: None,
613            ordered: false,
614            code: None,
615            language: None,
616            fields: Some(vec![FormField {
617                name: "region".to_string(),
618                path: None,
619                label: "Region scope".to_string(),
620                field_type: "select".to_string(),
621                placeholder: None,
622                required: true,
623                options: vec![SelectOption {
624                    label: "us-east-1 only".to_string(),
625                    value: "us-east-1".to_string(),
626                }],
627            }]),
628            submit_action: Some("review_rollback".to_string()),
629            submit_label: Some("Review guarded rollback".to_string()),
630            data_path_prefix: Some("/proposal".to_string()),
631        });
632
633        let Component::Card(card) = component else {
634            panic!("form section should render as a card");
635        };
636        assert!(
637            matches!(&card.content[0], Component::Select(select) if select.name == "/proposal/region")
638        );
639        assert!(
640            matches!(&card.content[1], Component::Button(button) if button.action_id == "review_rollback")
641        );
642    }
643}