Skip to main content

wisp/components/
elicitation_form.rs

1use acp_utils::notifications::{ElicitationAction, ElicitationParams, ElicitationResponse};
2use acp_utils::{
3    ConstTitle, ElicitationSchema, EnumSchema, MultiSelectEnumSchema, PrimitiveSchema,
4    SingleSelectEnumSchema,
5};
6use tokio::sync::oneshot;
7use tui::{Checkbox, MultiSelect, NumberField, RadioSelect, SelectOption, TextField};
8use tui::{Component, Event, Form, FormField, FormFieldKind, FormMessage, Frame, ViewContext};
9
10pub enum ElicitationMessage {
11    Responded,
12}
13
14pub struct ElicitationForm {
15    pub form: Form,
16    pub(crate) response_tx: Option<oneshot::Sender<ElicitationResponse>>,
17}
18
19impl Component for ElicitationForm {
20    type Message = ElicitationMessage;
21
22    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
23        let outcome = self.form.on_event(event).await?;
24        if let Some(msg) = outcome.into_iter().next() {
25            match msg {
26                FormMessage::Close => {
27                    let _ = self.response_tx.take().map(|tx| tx.send(Self::decline()));
28                    return Some(vec![ElicitationMessage::Responded]);
29                }
30                FormMessage::Submit => {
31                    let response = self.confirm();
32                    let _ = self.response_tx.take().map(|tx| tx.send(response));
33                    return Some(vec![ElicitationMessage::Responded]);
34                }
35            }
36        }
37        Some(vec![])
38    }
39
40    fn render(&mut self, ctx: &ViewContext) -> Frame {
41        self.form.render(ctx)
42    }
43}
44
45impl ElicitationForm {
46    pub fn from_params(
47        params: ElicitationParams,
48        response_tx: oneshot::Sender<ElicitationResponse>,
49    ) -> Self {
50        let fields = parse_schema(&params.schema);
51        Self {
52            form: Form::new(params.message, fields),
53            response_tx: Some(response_tx),
54        }
55    }
56
57    pub fn confirm(&self) -> ElicitationResponse {
58        ElicitationResponse {
59            action: ElicitationAction::Accept,
60            content: Some(self.form.to_json()),
61        }
62    }
63
64    pub fn decline() -> ElicitationResponse {
65        ElicitationResponse {
66            action: ElicitationAction::Decline,
67            content: None,
68        }
69    }
70}
71
72fn parse_schema(schema: &ElicitationSchema) -> Vec<FormField> {
73    let required = schema.required.as_deref().unwrap_or(&[]);
74    schema
75        .properties
76        .iter()
77        .map(|(name, prop)| {
78            let (title, description) = extract_metadata(prop);
79            FormField {
80                name: name.clone(),
81                label: title.unwrap_or_else(|| name.clone()),
82                description,
83                required: required.iter().any(|r| r == name),
84                kind: parse_field_kind(prop),
85            }
86        })
87        .collect()
88}
89
90fn parse_field_kind(prop: &PrimitiveSchema) -> FormFieldKind {
91    match prop {
92        PrimitiveSchema::Boolean(b) => {
93            FormFieldKind::Boolean(Checkbox::new(b.default.unwrap_or(false)))
94        }
95        PrimitiveSchema::Integer(_) => FormFieldKind::Number(NumberField::new(String::new(), true)),
96        PrimitiveSchema::Number(_) => FormFieldKind::Number(NumberField::new(String::new(), false)),
97        PrimitiveSchema::String(_) => FormFieldKind::Text(TextField::new(String::new())),
98        PrimitiveSchema::Enum(e) => parse_enum_field(e),
99    }
100}
101
102fn parse_enum_field(e: &EnumSchema) -> FormFieldKind {
103    match e {
104        EnumSchema::Single(s) => match s {
105            SingleSelectEnumSchema::Untitled(u) => {
106                let options = options_from_strings(&u.enum_);
107                let default_idx = u
108                    .default
109                    .as_ref()
110                    .and_then(|d| options.iter().position(|o| o.value == *d))
111                    .unwrap_or(0);
112                FormFieldKind::SingleSelect(RadioSelect::new(options, default_idx))
113            }
114            SingleSelectEnumSchema::Titled(t) => {
115                let options = options_from_const_titles(&t.one_of);
116                let default_idx = t
117                    .default
118                    .as_ref()
119                    .and_then(|d| options.iter().position(|o| o.value == *d))
120                    .unwrap_or(0);
121                FormFieldKind::SingleSelect(RadioSelect::new(options, default_idx))
122            }
123        },
124        EnumSchema::Multi(m) => match m {
125            MultiSelectEnumSchema::Untitled(u) => {
126                let options = options_from_strings(&u.items.enum_);
127                let defaults = u.default.as_deref().unwrap_or(&[]);
128                let selected: Vec<bool> = options
129                    .iter()
130                    .map(|o| defaults.contains(&o.value))
131                    .collect();
132                FormFieldKind::MultiSelect(MultiSelect::new(options, selected))
133            }
134            MultiSelectEnumSchema::Titled(t) => {
135                let options = options_from_const_titles(&t.items.any_of);
136                let defaults = t.default.as_deref().unwrap_or(&[]);
137                let selected: Vec<bool> = options
138                    .iter()
139                    .map(|o| defaults.contains(&o.value))
140                    .collect();
141                FormFieldKind::MultiSelect(MultiSelect::new(options, selected))
142            }
143        },
144        EnumSchema::Legacy(l) => {
145            let options = options_from_strings(&l.enum_);
146            FormFieldKind::SingleSelect(RadioSelect::new(options, 0))
147        }
148    }
149}
150
151fn extract_metadata(prop: &PrimitiveSchema) -> (Option<String>, Option<String>) {
152    match prop {
153        PrimitiveSchema::String(s) => (
154            s.title.as_ref().map(ToString::to_string),
155            s.description.as_ref().map(ToString::to_string),
156        ),
157        PrimitiveSchema::Number(n) => (
158            n.title.as_ref().map(ToString::to_string),
159            n.description.as_ref().map(ToString::to_string),
160        ),
161        PrimitiveSchema::Integer(i) => (
162            i.title.as_ref().map(ToString::to_string),
163            i.description.as_ref().map(ToString::to_string),
164        ),
165        PrimitiveSchema::Boolean(b) => (
166            b.title.as_ref().map(ToString::to_string),
167            b.description.as_ref().map(ToString::to_string),
168        ),
169        PrimitiveSchema::Enum(e) => extract_enum_metadata(e),
170    }
171}
172
173fn extract_enum_metadata(e: &EnumSchema) -> (Option<String>, Option<String>) {
174    match e {
175        EnumSchema::Single(s) => match s {
176            SingleSelectEnumSchema::Untitled(u) => (
177                u.title.as_ref().map(ToString::to_string),
178                u.description.as_ref().map(ToString::to_string),
179            ),
180            SingleSelectEnumSchema::Titled(t) => (
181                t.title.as_ref().map(ToString::to_string),
182                t.description.as_ref().map(ToString::to_string),
183            ),
184        },
185        EnumSchema::Multi(m) => match m {
186            MultiSelectEnumSchema::Untitled(u) => (
187                u.title.as_ref().map(ToString::to_string),
188                u.description.as_ref().map(ToString::to_string),
189            ),
190            MultiSelectEnumSchema::Titled(t) => (
191                t.title.as_ref().map(ToString::to_string),
192                t.description.as_ref().map(ToString::to_string),
193            ),
194        },
195        EnumSchema::Legacy(l) => (
196            l.title.as_ref().map(ToString::to_string),
197            l.description.as_ref().map(ToString::to_string),
198        ),
199    }
200}
201
202fn options_from_strings(values: &[String]) -> Vec<SelectOption> {
203    values
204        .iter()
205        .map(|s| SelectOption {
206            value: s.clone(),
207            title: s.clone(),
208            description: None,
209        })
210        .collect()
211}
212
213fn options_from_const_titles(items: &[ConstTitle]) -> Vec<SelectOption> {
214    items
215        .iter()
216        .map(|ct| SelectOption {
217            value: ct.const_.clone(),
218            title: ct.title.clone(),
219            description: None,
220        })
221        .collect()
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use acp_utils::EnumSchema;
228    use std::collections::BTreeMap;
229
230    fn test_schema() -> ElicitationSchema {
231        serde_json::from_value(serde_json::json!({
232            "type": "object",
233            "properties": {
234                "name": {
235                    "type": "string",
236                    "title": "Your Name",
237                    "description": "Enter your full name"
238                },
239                "age": {
240                    "type": "integer",
241                    "title": "Age",
242                    "minimum": 0,
243                    "maximum": 150
244                },
245                "rating": {
246                    "type": "number",
247                    "title": "Rating"
248                },
249                "approved": {
250                    "type": "boolean",
251                    "title": "Approved",
252                    "default": true
253                },
254                "color": {
255                    "type": "string",
256                    "title": "Favorite Color",
257                    "enum": ["red", "green", "blue"]
258                },
259                "tags": {
260                    "type": "array",
261                    "title": "Tags",
262                    "items": {
263                        "type": "string",
264                        "enum": ["fast", "reliable", "cheap"]
265                    }
266                }
267            },
268            "required": ["name", "color"]
269        }))
270        .unwrap()
271    }
272
273    #[test]
274    fn parse_schema_extracts_all_field_types() {
275        let schema = test_schema();
276        let fields = parse_schema(&schema);
277        assert_eq!(fields.len(), 6);
278
279        let name_field = fields.iter().find(|f| f.name == "name").unwrap();
280        assert_eq!(name_field.label, "Your Name");
281        assert!(name_field.required);
282        assert!(matches!(name_field.kind, FormFieldKind::Text(_)));
283
284        let age_field = fields.iter().find(|f| f.name == "age").unwrap();
285        match &age_field.kind {
286            FormFieldKind::Number(nf) => assert!(nf.integer_only),
287            _ => panic!("Expected Number (integer)"),
288        }
289
290        let bool_field = fields.iter().find(|f| f.name == "approved").unwrap();
291        match &bool_field.kind {
292            FormFieldKind::Boolean(cb) => assert!(cb.checked),
293            _ => panic!("Expected Boolean"),
294        }
295
296        let color_field = fields.iter().find(|f| f.name == "color").unwrap();
297        assert!(color_field.required);
298        match &color_field.kind {
299            FormFieldKind::SingleSelect(rs) => {
300                assert_eq!(rs.options.len(), 3);
301                assert_eq!(rs.options[0].value, "red");
302            }
303            _ => panic!("Expected SingleSelect"),
304        }
305
306        let tags_field = fields.iter().find(|f| f.name == "tags").unwrap();
307        match &tags_field.kind {
308            FormFieldKind::MultiSelect(ms) => {
309                assert_eq!(ms.options.len(), 3);
310                assert!(ms.selected.iter().all(|&s| !s));
311            }
312            _ => panic!("Expected MultiSelect"),
313        }
314    }
315
316    #[test]
317    fn confirm_produces_correct_json() {
318        let (tx, _rx) = oneshot::channel();
319        let params = ElicitationParams {
320            message: "Test".to_string(),
321            schema: ElicitationSchema::builder()
322                .optional_string("name")
323                .optional_bool("approved", true)
324                .optional_enum_schema(
325                    "color",
326                    EnumSchema::builder(vec!["red".into(), "green".into()])
327                        .untitled()
328                        .with_default("green")
329                        .unwrap()
330                        .build(),
331                )
332                .build()
333                .unwrap(),
334        };
335
336        let form = ElicitationForm::from_params(params, tx);
337        let response = form.confirm();
338
339        assert_eq!(response.action, ElicitationAction::Accept);
340        let content = response.content.unwrap();
341        assert_eq!(content["name"], "");
342        assert_eq!(content["approved"], true);
343        assert_eq!(content["color"], "green");
344    }
345
346    #[test]
347    fn esc_returns_decline() {
348        let response = ElicitationForm::decline();
349        assert_eq!(response.action, ElicitationAction::Decline);
350        assert!(response.content.is_none());
351    }
352
353    #[test]
354    fn one_of_string_produces_single_select() {
355        let schema: ElicitationSchema = serde_json::from_value(serde_json::json!({
356            "type": "object",
357            "properties": {
358                "size": {
359                    "type": "string",
360                    "oneOf": [
361                        { "const": "s", "title": "Small" },
362                        { "const": "m", "title": "Medium" },
363                        { "const": "l", "title": "Large" }
364                    ]
365                }
366            }
367        }))
368        .unwrap();
369        let fields = parse_schema(&schema);
370        assert_eq!(fields.len(), 1);
371        match &fields[0].kind {
372            FormFieldKind::SingleSelect(rs) => {
373                assert_eq!(rs.options.len(), 3);
374                assert_eq!(rs.options[0].title, "Small");
375                assert_eq!(rs.options[0].value, "s");
376            }
377            _ => panic!("Expected SingleSelect"),
378        }
379    }
380
381    #[test]
382    fn empty_schema_produces_no_fields() {
383        let schema = ElicitationSchema::new(BTreeMap::new());
384        let fields = parse_schema(&schema);
385        assert!(fields.is_empty());
386    }
387}