use crate::a2ui::{stable_child_id, stable_id};
use crate::compat::{Result, Tool, ToolContext};
use crate::schema::*;
use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RenderFormParams {
pub title: String,
#[serde(default)]
pub description: Option<String>,
pub fields: Vec<FormField>,
#[serde(default = "default_submit_action")]
pub submit_action: String,
#[serde(default = "default_submit_label")]
pub submit_label: String,
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub data_path_prefix: Option<String>,
#[serde(flatten)]
pub protocol: LegacyProtocolOptions,
}
fn default_submit_action() -> String {
"form_submit".to_string()
}
fn default_submit_label() -> String {
"Submit".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FormField {
pub name: String,
#[serde(default)]
pub path: Option<String>,
pub label: String,
#[serde(rename = "type", default = "default_field_type")]
pub field_type: String,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub options: Vec<SelectOption>,
}
fn default_field_type() -> String {
"text".to_string()
}
pub struct RenderFormTool;
impl RenderFormTool {
pub fn new() -> Self {
Self
}
}
impl Default for RenderFormTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for RenderFormTool {
fn name(&self) -> &str {
"render_form"
}
fn description(&self) -> &str {
r#"Render a form to collect user input. Output example:
┌─────────────────────────┐
│ Registration Form │
│ ─────────────────────── │
│ Name*: [___________] │
│ Email*: [___________] │
│ Password*: [___________]│
│ [Register] │
└─────────────────────────┘
Use field types: text, email, password, number, select, textarea. Set required=true for mandatory fields."#
}
fn parameters_schema(&self) -> Option<Value> {
Some(super::generate_gemini_schema::<RenderFormParams>())
}
async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
let params: RenderFormParams = serde_json::from_value(args)
.map_err(|e| crate::compat::AdkError::tool(format!("Invalid parameters: {}", e)))?;
let protocol_options = params.protocol.clone();
let form_id = stable_id(&format!("form:{}:{}", params.title, params.submit_action));
let mut form_content: Vec<Component> = Vec::new();
for field in params.fields {
let field_path = field.path.clone().unwrap_or_else(|| {
if let Some(prefix) = ¶ms.data_path_prefix {
let trimmed = prefix.trim_end_matches('/');
format!("{}/{}", trimmed, field.name)
} else {
field.name.clone()
}
});
let field_id = stable_child_id(&form_id, &format!("field:{}", field_path));
let component = match field.field_type.as_str() {
"number" => Component::NumberInput(NumberInput {
id: Some(field_id),
name: field_path,
label: field.label,
min: None,
max: None,
step: None,
required: field.required,
default_value: None,
error: None,
}),
"select" => Component::Select(Select {
id: Some(field_id),
name: field_path,
label: field.label,
options: field.options,
required: field.required,
error: None,
}),
"textarea" => Component::Textarea(Textarea {
id: Some(field_id),
name: field_path,
label: field.label,
placeholder: field.placeholder,
rows: 4,
required: field.required,
default_value: None,
error: None,
}),
_ => Component::TextInput(TextInput {
id: Some(field_id),
name: field_path,
label: field.label,
input_type: field.field_type.clone(),
placeholder: field.placeholder,
required: field.required,
default_value: None,
min_length: None,
max_length: None,
error: None,
}),
};
form_content.push(component);
}
form_content.push(Component::Button(Button {
id: Some(stable_child_id(&form_id, "submit")),
label: params.submit_label,
action_id: params.submit_action,
variant: ButtonVariant::Primary,
disabled: false,
icon: None,
}));
let mut ui = UiResponse::new(vec![Component::Card(Card {
id: Some(form_id),
title: Some(params.title),
description: params.description,
content: form_content,
footer: None,
})]);
if let Some(theme_str) = params.theme {
let theme = match theme_str.to_lowercase().as_str() {
"dark" => Theme::Dark,
"system" => Theme::System,
_ => Theme::Light,
};
ui = ui.with_theme(theme);
}
render_ui_response_with_protocol(ui, &protocol_options, "form")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::{Content, EventActions, ReadonlyContext};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
struct TestContext {
content: Content,
actions: Mutex<EventActions>,
}
impl TestContext {
fn new() -> Self {
Self {
content: Content::new("user"),
actions: Mutex::new(EventActions::default()),
}
}
}
#[async_trait]
impl ReadonlyContext for TestContext {
fn invocation_id(&self) -> &str {
"test"
}
fn agent_name(&self) -> &str {
"test"
}
fn user_id(&self) -> &str {
"user"
}
fn app_name(&self) -> &str {
"app"
}
fn session_id(&self) -> &str {
"session"
}
fn branch(&self) -> &str {
""
}
fn user_content(&self) -> &Content {
&self.content
}
}
#[async_trait]
impl crate::compat::CallbackContext for TestContext {
fn artifacts(&self) -> Option<Arc<dyn crate::compat::Artifacts>> {
None
}
}
#[async_trait]
impl ToolContext for TestContext {
fn function_call_id(&self) -> &str {
"call-123"
}
fn actions(&self) -> EventActions {
self.actions.lock().unwrap().clone()
}
fn set_actions(&self, actions: EventActions) {
*self.actions.lock().unwrap() = actions;
}
async fn search_memory(&self, _query: &str) -> Result<Vec<crate::compat::MemoryEntry>> {
Ok(vec![])
}
}
#[tokio::test]
async fn render_form_applies_binding_paths_and_ids() {
let tool = RenderFormTool::new();
let args = serde_json::json!({
"title": "Profile",
"fields": [
{ "name": "email", "label": "Email", "type": "email" },
{ "name": "name", "label": "Name", "type": "text", "path": "/account/name" }
],
"submit_action": "save_profile",
"data_path_prefix": "/user"
});
let ctx: Arc<dyn ToolContext> = Arc::new(TestContext::new());
let value = tool.execute(ctx, args).await.unwrap();
let ui: UiResponse = serde_json::from_value(value).unwrap();
let card = match &ui.components[0] {
Component::Card(card) => card,
_ => panic!("expected card"),
};
assert!(card.id.is_some());
let field_names: Vec<String> = card
.content
.iter()
.filter_map(|component| match component {
Component::TextInput(input) => Some(input.name.clone()),
_ => None,
})
.collect();
assert!(field_names.contains(&"/user/email".to_string()));
assert!(field_names.contains(&"/account/name".to_string()));
}
}