Skip to main content

adk_ui/tools/
render_form.rs

1use crate::a2ui::{stable_child_id, stable_id};
2use crate::compat::{Result, Tool, ToolContext};
3use crate::schema::*;
4use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
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
73pub(crate) fn build_form_content(
74    form_id: &str,
75    fields: Vec<FormField>,
76    data_path_prefix: Option<&str>,
77    submit_action: &str,
78    submit_label: &str,
79) -> Vec<Component> {
80    let mut form_content = Vec::new();
81
82    for field in fields {
83        let field_path = field.path.clone().unwrap_or_else(|| {
84            if let Some(prefix) = data_path_prefix {
85                let trimmed = prefix.trim_end_matches('/');
86                format!("{}/{}", trimmed, field.name)
87            } else {
88                field.name.clone()
89            }
90        });
91        let field_id = stable_child_id(form_id, &format!("field:{}", field_path));
92        let component = match field.field_type.as_str() {
93            "number" => Component::NumberInput(NumberInput {
94                id: Some(field_id),
95                name: field_path,
96                label: field.label,
97                min: None,
98                max: None,
99                step: None,
100                required: field.required,
101                default_value: None,
102                error: None,
103            }),
104            "select" => Component::Select(Select {
105                id: Some(field_id),
106                name: field_path,
107                label: field.label,
108                options: field.options,
109                required: field.required,
110                error: None,
111            }),
112            "textarea" => Component::Textarea(Textarea {
113                id: Some(field_id),
114                name: field_path,
115                label: field.label,
116                placeholder: field.placeholder,
117                rows: 4,
118                required: field.required,
119                default_value: None,
120                error: None,
121            }),
122            _ => Component::TextInput(TextInput {
123                id: Some(field_id),
124                name: field_path,
125                label: field.label,
126                input_type: field.field_type,
127                placeholder: field.placeholder,
128                required: field.required,
129                default_value: None,
130                min_length: None,
131                max_length: None,
132                error: None,
133            }),
134        };
135        form_content.push(component);
136    }
137
138    form_content.push(Component::Button(Button {
139        id: Some(stable_child_id(form_id, "submit")),
140        label: submit_label.to_string(),
141        action_id: submit_action.to_string(),
142        variant: ButtonVariant::Primary,
143        disabled: false,
144        icon: None,
145    }));
146
147    form_content
148}
149
150/// Tool for rendering forms to collect user input.
151///
152/// This tool generates form UI components that allow agents to collect
153/// structured input from users. The form includes various field types
154/// and returns submitted data via `UiEvent::FormSubmit`.
155///
156/// # Supported Field Types
157///
158/// - `text`: Single-line text input (default)
159/// - `email`: Email address input with validation
160/// - `password`: Password input (masked)
161/// - `number`: Numeric input
162/// - `select`: Dropdown selection from options
163/// - `textarea`: Multi-line text input
164///
165/// # Example JSON Parameters
166///
167/// ```json
168/// {
169///   "title": "Contact Form",
170///   "description": "Please fill out your details",
171///   "fields": [
172///     { "name": "email", "label": "Email", "type": "email", "required": true },
173///     { "name": "message", "label": "Message", "type": "textarea" }
174///   ],
175///   "submit_label": "Send"
176/// }
177/// ```
178pub struct RenderFormTool;
179
180impl RenderFormTool {
181    pub fn new() -> Self {
182        Self
183    }
184}
185
186impl Default for RenderFormTool {
187    fn default() -> Self {
188        Self::new()
189    }
190}
191
192#[async_trait]
193impl Tool for RenderFormTool {
194    fn name(&self) -> &str {
195        "render_form"
196    }
197
198    fn description(&self) -> &str {
199        r#"Render a form to collect user input. Output example:
200┌─────────────────────────┐
201│ Registration Form       │
202│ ─────────────────────── │
203│ Name*: [___________]    │
204│ Email*: [___________]   │
205│ Password*: [___________]│
206│         [Register]      │
207└─────────────────────────┘
208Use field types: text, email, password, number, select, textarea. Set required=true for mandatory fields."#
209    }
210
211    fn parameters_schema(&self) -> Option<Value> {
212        Some(super::generate_gemini_schema::<RenderFormParams>())
213    }
214
215    async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
216        let params: RenderFormParams = serde_json::from_value(args)
217            .map_err(|e| crate::compat::AdkError::tool(format!("Invalid parameters: {}", e)))?;
218        let protocol_options = params.protocol.clone();
219
220        let form_id = stable_id(&format!("form:{}:{}", params.title, params.submit_action));
221        let form_content = build_form_content(
222            &form_id,
223            params.fields,
224            params.data_path_prefix.as_deref(),
225            &params.submit_action,
226            &params.submit_label,
227        );
228
229        // Wrap in a card
230        let mut ui = UiResponse::new(vec![Component::Card(Card {
231            id: Some(form_id),
232            title: Some(params.title),
233            description: params.description,
234            content: form_content,
235            footer: None,
236        })]);
237
238        // Apply theme if specified
239        if let Some(theme_str) = params.theme {
240            let theme = match theme_str.to_lowercase().as_str() {
241                "dark" => Theme::Dark,
242                "system" => Theme::System,
243                _ => Theme::Light,
244            };
245            ui = ui.with_theme(theme);
246        }
247
248        let surface_id = protocol_options.resolved_surface_id("form");
249        let output = render_ui_response_with_protocol(ui, &protocol_options, "form")?;
250        let surface_ref = crate::surface_runtime::next_surface_ref(&ctx, surface_id);
251        crate::surface_runtime::record_surface_ref(&ctx, &surface_ref);
252        Ok(output)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::compat::{Content, EventActions, ReadonlyContext};
260    use async_trait::async_trait;
261    use std::sync::{Arc, Mutex};
262
263    struct TestContext {
264        content: Content,
265        actions: Mutex<EventActions>,
266    }
267
268    impl TestContext {
269        fn new() -> Self {
270            Self {
271                content: Content::new("user"),
272                actions: Mutex::new(EventActions::default()),
273            }
274        }
275    }
276
277    #[async_trait]
278    impl ReadonlyContext for TestContext {
279        fn invocation_id(&self) -> &str {
280            "test"
281        }
282        fn agent_name(&self) -> &str {
283            "test"
284        }
285        fn user_id(&self) -> &str {
286            "user"
287        }
288        fn app_name(&self) -> &str {
289            "app"
290        }
291        fn session_id(&self) -> &str {
292            "session"
293        }
294        fn branch(&self) -> &str {
295            ""
296        }
297        fn user_content(&self) -> &Content {
298            &self.content
299        }
300    }
301
302    #[async_trait]
303    impl crate::compat::CallbackContext for TestContext {
304        fn artifacts(&self) -> Option<Arc<dyn crate::compat::Artifacts>> {
305            None
306        }
307    }
308
309    #[async_trait]
310    impl ToolContext for TestContext {
311        fn function_call_id(&self) -> &str {
312            "call-123"
313        }
314        fn actions(&self) -> EventActions {
315            self.actions.lock().unwrap().clone()
316        }
317        fn set_actions(&self, actions: EventActions) {
318            *self.actions.lock().unwrap() = actions;
319        }
320        async fn search_memory(&self, _query: &str) -> Result<Vec<crate::compat::MemoryEntry>> {
321            Ok(vec![])
322        }
323    }
324
325    #[tokio::test]
326    async fn render_form_applies_binding_paths_and_ids() {
327        let tool = RenderFormTool::new();
328        let args = serde_json::json!({
329            "title": "Profile",
330            "fields": [
331                { "name": "email", "label": "Email", "type": "email" },
332                { "name": "name", "label": "Name", "type": "text", "path": "/account/name" }
333            ],
334            "submit_action": "save_profile",
335            "data_path_prefix": "/user"
336        });
337
338        let ctx: Arc<dyn ToolContext> = Arc::new(TestContext::new());
339        let value = tool.execute(ctx, args).await.unwrap();
340        let ui: UiResponse = serde_json::from_value(value).unwrap();
341
342        let card = match &ui.components[0] {
343            Component::Card(card) => card,
344            _ => panic!("expected card"),
345        };
346
347        assert!(card.id.is_some());
348        let field_names: Vec<String> = card
349            .content
350            .iter()
351            .filter_map(|component| match component {
352                Component::TextInput(input) => Some(input.name.clone()),
353                _ => None,
354            })
355            .collect();
356
357        assert!(field_names.contains(&"/user/email".to_string()));
358        assert!(field_names.contains(&"/account/name".to_string()));
359    }
360}