Skip to main content

adk_ui/tools/
render_form.rs

1use crate::a2ui::{stable_child_id, stable_id};
2use crate::schema::*;
3use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
4use adk_core::{Result, Tool, ToolContext};
5use async_trait::async_trait;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::sync::Arc;
10
11/// Parameters for the render_form tool
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct RenderFormParams {
14    /// Title of the form
15    pub title: String,
16    /// Optional description
17    #[serde(default)]
18    pub description: Option<String>,
19    /// Form fields to render
20    pub fields: Vec<FormField>,
21    /// Action ID for form submission
22    #[serde(default = "default_submit_action")]
23    pub submit_action: String,
24    /// Submit button label
25    #[serde(default = "default_submit_label")]
26    pub submit_label: String,
27    /// Theme: "light", "dark", or "system" (default: "light")
28    #[serde(default)]
29    pub theme: Option<String>,
30    /// Optional data path prefix for binding form fields (e.g. "/user")
31    #[serde(default)]
32    pub data_path_prefix: Option<String>,
33    /// Optional protocol output configuration.
34    #[serde(flatten)]
35    pub protocol: LegacyProtocolOptions,
36}
37
38fn default_submit_action() -> String {
39    "form_submit".to_string()
40}
41
42fn default_submit_label() -> String {
43    "Submit".to_string()
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
47pub struct FormField {
48    /// Field name (used as key in submission)
49    pub name: String,
50    /// Optional binding path override (e.g. "/user/email")
51    #[serde(default)]
52    pub path: Option<String>,
53    /// Label displayed to user
54    pub label: String,
55    /// Field type: text, email, password, number, date, select
56    #[serde(rename = "type", default = "default_field_type")]
57    pub field_type: String,
58    /// Placeholder text
59    #[serde(default)]
60    pub placeholder: Option<String>,
61    /// Whether the field is required
62    #[serde(default)]
63    pub required: bool,
64    /// Options for select fields
65    #[serde(default)]
66    pub options: Vec<SelectOption>,
67}
68
69fn default_field_type() -> String {
70    "text".to_string()
71}
72
73/// Tool for rendering forms to collect user input.
74///
75/// This tool generates form UI components that allow agents to collect
76/// structured input from users. The form includes various field types
77/// and returns submitted data via `UiEvent::FormSubmit`.
78///
79/// # Supported Field Types
80///
81/// - `text`: Single-line text input (default)
82/// - `email`: Email address input with validation
83/// - `password`: Password input (masked)
84/// - `number`: Numeric input
85/// - `select`: Dropdown selection from options
86/// - `textarea`: Multi-line text input
87///
88/// # Example JSON Parameters
89///
90/// ```json
91/// {
92///   "title": "Contact Form",
93///   "description": "Please fill out your details",
94///   "fields": [
95///     { "name": "email", "label": "Email", "type": "email", "required": true },
96///     { "name": "message", "label": "Message", "type": "textarea" }
97///   ],
98///   "submit_label": "Send"
99/// }
100/// ```
101pub struct RenderFormTool;
102
103impl RenderFormTool {
104    pub fn new() -> Self {
105        Self
106    }
107}
108
109impl Default for RenderFormTool {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115#[async_trait]
116impl Tool for RenderFormTool {
117    fn name(&self) -> &str {
118        "render_form"
119    }
120
121    fn description(&self) -> &str {
122        r#"Render a form to collect user input. Output example:
123┌─────────────────────────┐
124│ Registration Form       │
125│ ─────────────────────── │
126│ Name*: [___________]    │
127│ Email*: [___________]   │
128│ Password*: [___________]│
129│         [Register]      │
130└─────────────────────────┘
131Use field types: text, email, password, number, select, textarea. Set required=true for mandatory fields."#
132    }
133
134    fn parameters_schema(&self) -> Option<Value> {
135        Some(super::generate_gemini_schema::<RenderFormParams>())
136    }
137
138    async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
139        let params: RenderFormParams = serde_json::from_value(args)
140            .map_err(|e| adk_core::AdkError::Tool(format!("Invalid parameters: {}", e)))?;
141        let protocol_options = params.protocol.clone();
142
143        let form_id = stable_id(&format!("form:{}:{}", params.title, params.submit_action));
144        // Build the form UI
145        let mut form_content: Vec<Component> = Vec::new();
146
147        for field in params.fields {
148            let field_path = field.path.clone().unwrap_or_else(|| {
149                if let Some(prefix) = &params.data_path_prefix {
150                    let trimmed = prefix.trim_end_matches('/');
151                    format!("{}/{}", trimmed, field.name)
152                } else {
153                    field.name.clone()
154                }
155            });
156            let field_id = stable_child_id(&form_id, &format!("field:{}", field_path));
157            let component = match field.field_type.as_str() {
158                "number" => Component::NumberInput(NumberInput {
159                    id: Some(field_id),
160                    name: field_path,
161                    label: field.label,
162                    min: None,
163                    max: None,
164                    step: None,
165                    required: field.required,
166                    default_value: None,
167                    error: None,
168                }),
169                "select" => Component::Select(Select {
170                    id: Some(field_id),
171                    name: field_path,
172                    label: field.label,
173                    options: field.options,
174                    required: field.required,
175                    error: None,
176                }),
177                "textarea" => Component::Textarea(Textarea {
178                    id: Some(field_id),
179                    name: field_path,
180                    label: field.label,
181                    placeholder: field.placeholder,
182                    rows: 4,
183                    required: field.required,
184                    default_value: None,
185                    error: None,
186                }),
187                _ => Component::TextInput(TextInput {
188                    id: Some(field_id),
189                    name: field_path,
190                    label: field.label,
191                    input_type: field.field_type.clone(),
192                    placeholder: field.placeholder,
193                    required: field.required,
194                    default_value: None,
195                    min_length: None,
196                    max_length: None,
197                    error: None,
198                }),
199            };
200            form_content.push(component);
201        }
202
203        // Add submit button
204        form_content.push(Component::Button(Button {
205            id: Some(stable_child_id(&form_id, "submit")),
206            label: params.submit_label,
207            action_id: params.submit_action,
208            variant: ButtonVariant::Primary,
209            disabled: false,
210            icon: None,
211        }));
212
213        // Wrap in a card
214        let mut ui = UiResponse::new(vec![Component::Card(Card {
215            id: Some(form_id),
216            title: Some(params.title),
217            description: params.description,
218            content: form_content,
219            footer: None,
220        })]);
221
222        // Apply theme if specified
223        if let Some(theme_str) = params.theme {
224            let theme = match theme_str.to_lowercase().as_str() {
225                "dark" => Theme::Dark,
226                "system" => Theme::System,
227                _ => Theme::Light,
228            };
229            ui = ui.with_theme(theme);
230        }
231
232        render_ui_response_with_protocol(ui, &protocol_options, "form")
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use adk_core::{Content, EventActions, ReadonlyContext};
240    use async_trait::async_trait;
241    use std::sync::{Arc, Mutex};
242
243    struct TestContext {
244        content: Content,
245        actions: Mutex<EventActions>,
246    }
247
248    impl TestContext {
249        fn new() -> Self {
250            Self { content: Content::new("user"), actions: Mutex::new(EventActions::default()) }
251        }
252    }
253
254    #[async_trait]
255    impl ReadonlyContext for TestContext {
256        fn invocation_id(&self) -> &str {
257            "test"
258        }
259        fn agent_name(&self) -> &str {
260            "test"
261        }
262        fn user_id(&self) -> &str {
263            "user"
264        }
265        fn app_name(&self) -> &str {
266            "app"
267        }
268        fn session_id(&self) -> &str {
269            "session"
270        }
271        fn branch(&self) -> &str {
272            ""
273        }
274        fn user_content(&self) -> &Content {
275            &self.content
276        }
277    }
278
279    #[async_trait]
280    impl adk_core::CallbackContext for TestContext {
281        fn artifacts(&self) -> Option<Arc<dyn adk_core::Artifacts>> {
282            None
283        }
284    }
285
286    #[async_trait]
287    impl ToolContext for TestContext {
288        fn function_call_id(&self) -> &str {
289            "call-123"
290        }
291        fn actions(&self) -> EventActions {
292            self.actions.lock().unwrap().clone()
293        }
294        fn set_actions(&self, actions: EventActions) {
295            *self.actions.lock().unwrap() = actions;
296        }
297        async fn search_memory(&self, _query: &str) -> Result<Vec<adk_core::MemoryEntry>> {
298            Ok(vec![])
299        }
300    }
301
302    #[tokio::test]
303    async fn render_form_applies_binding_paths_and_ids() {
304        let tool = RenderFormTool::new();
305        let args = serde_json::json!({
306            "title": "Profile",
307            "fields": [
308                { "name": "email", "label": "Email", "type": "email" },
309                { "name": "name", "label": "Name", "type": "text", "path": "/account/name" }
310            ],
311            "submit_action": "save_profile",
312            "data_path_prefix": "/user"
313        });
314
315        let ctx: Arc<dyn ToolContext> = Arc::new(TestContext::new());
316        let value = tool.execute(ctx, args).await.unwrap();
317        let ui: UiResponse = serde_json::from_value(value).unwrap();
318
319        let card = match &ui.components[0] {
320            Component::Card(card) => card,
321            _ => panic!("expected card"),
322        };
323
324        assert!(card.id.is_some());
325        let field_names: Vec<String> = card
326            .content
327            .iter()
328            .filter_map(|component| match component {
329                Component::TextInput(input) => Some(input.name.clone()),
330                _ => None,
331            })
332            .collect();
333
334        assert!(field_names.contains(&"/user/email".to_string()));
335        assert!(field_names.contains(&"/account/name".to_string()));
336    }
337}