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(¶ms.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}