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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26pub struct DashboardSection {
27 pub title: String,
29 #[serde(rename = "type")]
31 pub section_type: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub stats: Option<Vec<StatItem>>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub text: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub message: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub severity: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub columns: Option<Vec<ColumnSpec>>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub rows: Option<Vec<HashMap<String, Value>>>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub chart_type: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub data: Option<Vec<HashMap<String, Value>>>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub x_key: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub y_keys: Option<Vec<String>>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub pairs: Option<Vec<KeyValueItem>>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub items: Option<Vec<String>>,
68 #[serde(default)]
70 pub ordered: bool,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub code: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub language: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub fields: Option<Vec<FormField>>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub submit_action: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub submit_label: Option<String>,
86 #[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 pub label: String,
95 pub value: String,
97 #[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 pub header: String,
106 pub key: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
112pub struct KeyValueItem {
113 pub key: String,
115 pub value: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
121pub struct RenderLayoutParams {
122 pub title: String,
124 #[serde(default)]
126 pub description: Option<String>,
127 pub sections: Vec<DashboardSection>,
129 #[serde(default)]
131 pub theme: Option<String>,
132 #[serde(flatten)]
134 pub protocol: LegacyProtocolOptions,
135}
136
137pub 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 components.push(Component::Text(Text {
228 id: None,
229 content: params.title,
230 variant: TextVariant::H2,
231 }));
232
233 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 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 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 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 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}