Skip to main content

wisp/components/
elicitation_form.rs

1use acp_utils::notifications::{
2    CreateElicitationRequestParams, ElicitationAction, ElicitationParams, ElicitationResponse,
3};
4use acp_utils::{
5    ConstTitle, ElicitationSchema, EnumSchema, MultiSelectEnumSchema, PrimitiveSchema, SingleSelectEnumSchema,
6};
7use agent_client_protocol::Responder;
8use std::process::Command;
9use std::sync::Arc;
10use tui::{
11    Checkbox, Component, Event, Form, FormField, FormFieldKind, FormMessage, Frame, MultiSelect, NumberField,
12    RadioSelect, SelectOption, TextField, ViewContext,
13};
14
15pub enum ElicitationMessage {
16    Responded,
17    /// Emitted when a URL modal successfully opens the browser.
18    UrlOpened {
19        elicitation_id: String,
20        server_name: String,
21    },
22}
23
24pub enum ElicitationUi {
25    Form(Form),
26    Url(UrlPrompt),
27}
28
29pub struct UrlPrompt {
30    pub server_name: String,
31    pub elicitation_id: String,
32    pub message: String,
33    pub url: String,
34    pub host: Option<String>,
35    pub warnings: Vec<String>,
36    pub launch_error: Option<String>,
37}
38
39type BrowserOpener = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
40
41pub struct ElicitationForm {
42    pub ui: ElicitationUi,
43    browser_opener: BrowserOpener,
44    pub(crate) responder: Option<Responder<ElicitationResponse>>,
45}
46
47impl UrlPrompt {
48    pub fn new(server_name: String, elicitation_id: String, message: String, url: String) -> Self {
49        let parsed_url = url::Url::parse(&url);
50        let host = parsed_url.as_ref().ok().and_then(|parsed| parsed.host_str().map(std::string::ToString::to_string));
51
52        let mut warnings = Vec::new();
53        match parsed_url {
54            Ok(parsed_url) => {
55                if let Some(ref h) = host
56                    && h.contains("xn--")
57                {
58                    warnings.push(
59                        "Warning: URL contains punycode (internationalized domain). Verify the domain before proceeding."
60                            .to_string(),
61                    );
62                }
63                if parsed_url.scheme() != "https" && !is_local_http_url(&parsed_url) {
64                    warnings.push("Warning: URL does not use HTTPS.".to_string());
65                }
66            }
67            Err(_) => {
68                warnings.push("Warning: URL could not be parsed. Verify it carefully before proceeding.".to_string());
69            }
70        }
71
72        Self { server_name, elicitation_id, message, url, host, warnings, launch_error: None }
73    }
74}
75
76impl Component for ElicitationForm {
77    type Message = ElicitationMessage;
78
79    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
80        match &mut self.ui {
81            ElicitationUi::Form(form) => {
82                let outcome = form.on_event(event).await?;
83                if let Some(msg) = outcome.into_iter().next() {
84                    match msg {
85                        FormMessage::Close => {
86                            let _ = self.responder.take().map(|r| r.respond(Self::cancel()));
87                            return Some(vec![ElicitationMessage::Responded]);
88                        }
89                        FormMessage::Submit => {
90                            let response = self.confirm();
91                            let _ = self.responder.take().map(|r| r.respond(response));
92                            return Some(vec![ElicitationMessage::Responded]);
93                        }
94                    }
95                }
96                Some(vec![])
97            }
98            ElicitationUi::Url(prompt) => {
99                let Event::Key(key) = event else {
100                    return Some(vec![]);
101                };
102                match key.code {
103                    tui::KeyCode::Enter => match (self.browser_opener)(&prompt.url) {
104                        Ok(()) => {
105                            let server_name = prompt.server_name.clone();
106                            let elicitation_id = prompt.elicitation_id.clone();
107                            let _ = self.responder.take().map(|r| {
108                                r.respond(ElicitationResponse { action: ElicitationAction::Accept, content: None })
109                            });
110                            return Some(vec![
111                                ElicitationMessage::Responded,
112                                ElicitationMessage::UrlOpened { elicitation_id, server_name },
113                            ]);
114                        }
115                        Err(e) => {
116                            prompt.launch_error = Some(format!("Failed to open browser: {e}"));
117                        }
118                    },
119                    tui::KeyCode::Char('d' | 'D') => {
120                        let _ = self.responder.take().map(|r| r.respond(Self::decline()));
121                        return Some(vec![ElicitationMessage::Responded]);
122                    }
123                    tui::KeyCode::Esc => {
124                        let _ = self.responder.take().map(|r| r.respond(Self::cancel()));
125                        return Some(vec![ElicitationMessage::Responded]);
126                    }
127                    _ => {}
128                }
129                Some(vec![])
130            }
131        }
132    }
133
134    fn render(&mut self, ctx: &ViewContext) -> Frame {
135        match &mut self.ui {
136            ElicitationUi::Form(form) => form.render(ctx),
137            ElicitationUi::Url(prompt) => render_url_prompt(prompt, ctx),
138        }
139    }
140}
141
142impl ElicitationForm {
143    pub fn from_params(params: ElicitationParams, responder: Responder<ElicitationResponse>) -> Self {
144        Self::with_browser_opener(params, responder, default_browser_opener)
145    }
146
147    pub fn with_browser_opener<F>(
148        params: ElicitationParams,
149        responder: Responder<ElicitationResponse>,
150        browser_opener: F,
151    ) -> Self
152    where
153        F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
154    {
155        let ui = match params.request {
156            CreateElicitationRequestParams::FormElicitationParams { message, requested_schema, .. } => {
157                let fields = parse_schema(&requested_schema);
158                ElicitationUi::Form(Form::new(message, fields))
159            }
160            CreateElicitationRequestParams::UrlElicitationParams { message, url, elicitation_id, .. } => {
161                ElicitationUi::Url(UrlPrompt::new(params.server_name, elicitation_id, message, url))
162            }
163        };
164        Self { ui, browser_opener: Arc::new(browser_opener), responder: Some(responder) }
165    }
166
167    pub fn confirm(&self) -> ElicitationResponse {
168        match &self.ui {
169            ElicitationUi::Form(form) => {
170                ElicitationResponse { action: ElicitationAction::Accept, content: Some(form.to_json()) }
171            }
172            ElicitationUi::Url(_) => ElicitationResponse { action: ElicitationAction::Accept, content: None },
173        }
174    }
175
176    pub fn decline() -> ElicitationResponse {
177        ElicitationResponse { action: ElicitationAction::Decline, content: None }
178    }
179
180    pub fn cancel() -> ElicitationResponse {
181        ElicitationResponse { action: ElicitationAction::Cancel, content: None }
182    }
183}
184
185fn render_url_prompt(prompt: &UrlPrompt, ctx: &ViewContext) -> Frame {
186    use tui::{Line, Style};
187
188    let mut lines = Vec::new();
189    let primary = ctx.theme.primary();
190    let text_primary = ctx.theme.text_primary();
191    let text_secondary = ctx.theme.text_secondary();
192    let warning_color = ctx.theme.warning();
193    let muted = ctx.theme.muted();
194
195    lines.push(Line::with_style(
196        format!("{} requests you to open a URL", prompt.server_name),
197        Style::fg(primary).bold(),
198    ));
199    lines.push(Line::default());
200    lines.push(Line::with_style(&prompt.message, Style::fg(text_primary)));
201    lines.push(Line::default());
202    lines.push(Line::with_style("URL:", Style::fg(text_secondary)));
203    lines.push(Line::with_style(&prompt.url, Style::fg(primary)));
204
205    if let Some(ref host) = prompt.host {
206        lines.push(Line::with_style(format!("Host: {host}"), Style::fg(text_secondary)));
207    }
208
209    if !prompt.warnings.is_empty() {
210        lines.push(Line::default());
211        for warning in &prompt.warnings {
212            lines.push(Line::styled(warning, warning_color));
213        }
214    }
215
216    if let Some(ref error) = prompt.launch_error {
217        lines.push(Line::default());
218        lines.push(Line::styled(error, ctx.theme.error()));
219    }
220
221    lines.push(Line::default());
222    lines.push(Line::styled("Enter to open URL · D to decline · Esc to cancel", muted));
223
224    Frame::new(lines)
225}
226
227fn is_local_http_url(url: &url::Url) -> bool {
228    if url.scheme() != "http" {
229        return false;
230    }
231
232    matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
233}
234
235fn default_browser_opener(url: &str) -> Result<(), String> {
236    #[cfg(target_os = "macos")]
237    {
238        let status = Command::new("open").arg(url).status().map_err(|e| e.to_string())?;
239        return status.success().then_some(()).ok_or_else(|| format!("open exited with status {status}"));
240    }
241
242    #[cfg(target_os = "linux")]
243    {
244        let status = Command::new("xdg-open").arg(url).status().map_err(|e| e.to_string())?;
245        return status.success().then_some(()).ok_or_else(|| format!("xdg-open exited with status {status}"));
246    }
247
248    #[cfg(target_os = "windows")]
249    {
250        let status = Command::new("cmd").args(["/C", "start", url]).status().map_err(|e| e.to_string())?;
251        return status.success().then_some(()).ok_or_else(|| format!("start exited with status {status}"));
252    }
253
254    #[allow(unreachable_code)]
255    Err("Unsupported platform for opening URLs".to_string())
256}
257
258fn parse_schema(schema: &ElicitationSchema) -> Vec<FormField> {
259    let required = schema.required.as_deref().unwrap_or(&[]);
260    schema
261        .properties
262        .iter()
263        .map(|(name, prop)| {
264            let (title, description) = extract_metadata(prop);
265            FormField {
266                name: name.clone(),
267                label: title.unwrap_or_else(|| name.clone()),
268                description,
269                required: required.iter().any(|r| r == name),
270                kind: parse_field_kind(prop),
271            }
272        })
273        .collect()
274}
275
276fn parse_field_kind(prop: &PrimitiveSchema) -> FormFieldKind {
277    match prop {
278        PrimitiveSchema::Boolean(b) => FormFieldKind::Boolean(Checkbox::new(b.default.unwrap_or(false))),
279        PrimitiveSchema::Integer(_) => FormFieldKind::Number(NumberField::new(String::new(), true)),
280        PrimitiveSchema::Number(_) => FormFieldKind::Number(NumberField::new(String::new(), false)),
281        PrimitiveSchema::String(_) => FormFieldKind::Text(TextField::new(String::new())),
282        PrimitiveSchema::Enum(e) => parse_enum_field(e),
283    }
284}
285
286fn parse_enum_field(e: &EnumSchema) -> FormFieldKind {
287    match e {
288        EnumSchema::Single(s) => match s {
289            SingleSelectEnumSchema::Untitled(u) => {
290                let options = options_from_strings(&u.enum_);
291                let default_idx =
292                    u.default.as_ref().and_then(|d| options.iter().position(|o| o.value == *d)).unwrap_or(0);
293                FormFieldKind::SingleSelect(RadioSelect::new(options, default_idx))
294            }
295            SingleSelectEnumSchema::Titled(t) => {
296                let options = options_from_const_titles(&t.one_of);
297                let default_idx =
298                    t.default.as_ref().and_then(|d| options.iter().position(|o| o.value == *d)).unwrap_or(0);
299                FormFieldKind::SingleSelect(RadioSelect::new(options, default_idx))
300            }
301        },
302        EnumSchema::Multi(m) => match m {
303            MultiSelectEnumSchema::Untitled(u) => {
304                let options = options_from_strings(&u.items.enum_);
305                let defaults = u.default.as_deref().unwrap_or(&[]);
306                let selected: Vec<bool> = options.iter().map(|o| defaults.contains(&o.value)).collect();
307                FormFieldKind::MultiSelect(MultiSelect::new(options, selected))
308            }
309            MultiSelectEnumSchema::Titled(t) => {
310                let options = options_from_const_titles(&t.items.any_of);
311                let defaults = t.default.as_deref().unwrap_or(&[]);
312                let selected: Vec<bool> = options.iter().map(|o| defaults.contains(&o.value)).collect();
313                FormFieldKind::MultiSelect(MultiSelect::new(options, selected))
314            }
315        },
316        EnumSchema::Legacy(l) => {
317            let options = options_from_strings(&l.enum_);
318            FormFieldKind::SingleSelect(RadioSelect::new(options, 0))
319        }
320    }
321}
322
323fn extract_metadata(prop: &PrimitiveSchema) -> (Option<String>, Option<String>) {
324    match prop {
325        PrimitiveSchema::String(s) => {
326            (s.title.as_ref().map(ToString::to_string), s.description.as_ref().map(ToString::to_string))
327        }
328        PrimitiveSchema::Number(n) => {
329            (n.title.as_ref().map(ToString::to_string), n.description.as_ref().map(ToString::to_string))
330        }
331        PrimitiveSchema::Integer(i) => {
332            (i.title.as_ref().map(ToString::to_string), i.description.as_ref().map(ToString::to_string))
333        }
334        PrimitiveSchema::Boolean(b) => {
335            (b.title.as_ref().map(ToString::to_string), b.description.as_ref().map(ToString::to_string))
336        }
337        PrimitiveSchema::Enum(e) => extract_enum_metadata(e),
338    }
339}
340
341fn extract_enum_metadata(e: &EnumSchema) -> (Option<String>, Option<String>) {
342    match e {
343        EnumSchema::Single(s) => match s {
344            SingleSelectEnumSchema::Untitled(u) => {
345                (u.title.as_ref().map(ToString::to_string), u.description.as_ref().map(ToString::to_string))
346            }
347            SingleSelectEnumSchema::Titled(t) => {
348                (t.title.as_ref().map(ToString::to_string), t.description.as_ref().map(ToString::to_string))
349            }
350        },
351        EnumSchema::Multi(m) => match m {
352            MultiSelectEnumSchema::Untitled(u) => {
353                (u.title.as_ref().map(ToString::to_string), u.description.as_ref().map(ToString::to_string))
354            }
355            MultiSelectEnumSchema::Titled(t) => {
356                (t.title.as_ref().map(ToString::to_string), t.description.as_ref().map(ToString::to_string))
357            }
358        },
359        EnumSchema::Legacy(l) => {
360            (l.title.as_ref().map(ToString::to_string), l.description.as_ref().map(ToString::to_string))
361        }
362    }
363}
364
365fn options_from_strings(values: &[String]) -> Vec<SelectOption> {
366    values.iter().map(|s| SelectOption { value: s.clone(), title: s.clone(), description: None }).collect()
367}
368
369fn options_from_const_titles(items: &[ConstTitle]) -> Vec<SelectOption> {
370    items
371        .iter()
372        .map(|ct| SelectOption { value: ct.const_.clone(), title: ct.title.clone(), description: None })
373        .collect()
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use acp_utils::EnumSchema;
380    use acp_utils::testing::test_connection;
381    use std::collections::BTreeMap;
382    use std::sync::{Arc, Mutex};
383    use tokio::task::LocalSet;
384
385    fn test_schema() -> ElicitationSchema {
386        serde_json::from_value(serde_json::json!({
387            "type": "object",
388            "properties": {
389                "name": {
390                    "type": "string",
391                    "title": "Your Name",
392                    "description": "Enter your full name"
393                },
394                "age": {
395                    "type": "integer",
396                    "title": "Age",
397                    "minimum": 0,
398                    "maximum": 150
399                },
400                "rating": {
401                    "type": "number",
402                    "title": "Rating"
403                },
404                "approved": {
405                    "type": "boolean",
406                    "title": "Approved",
407                    "default": true
408                },
409                "color": {
410                    "type": "string",
411                    "title": "Favorite Color",
412                    "enum": ["red", "green", "blue"]
413                },
414                "tags": {
415                    "type": "array",
416                    "title": "Tags",
417                    "items": {
418                        "type": "string",
419                        "enum": ["fast", "reliable", "cheap"]
420                    }
421                }
422            },
423            "required": ["name", "color"]
424        }))
425        .unwrap()
426    }
427
428    #[test]
429    fn parse_schema_extracts_all_field_types() {
430        let schema = test_schema();
431        let fields = parse_schema(&schema);
432        assert_eq!(fields.len(), 6);
433
434        let name_field = fields.iter().find(|f| f.name == "name").unwrap();
435        assert_eq!(name_field.label, "Your Name");
436        assert!(name_field.required);
437        assert!(matches!(name_field.kind, FormFieldKind::Text(_)));
438
439        let age_field = fields.iter().find(|f| f.name == "age").unwrap();
440        match &age_field.kind {
441            FormFieldKind::Number(nf) => assert!(nf.integer_only),
442            _ => panic!("Expected Number (integer)"),
443        }
444
445        let bool_field = fields.iter().find(|f| f.name == "approved").unwrap();
446        match &bool_field.kind {
447            FormFieldKind::Boolean(cb) => assert!(cb.checked),
448            _ => panic!("Expected Boolean"),
449        }
450
451        let color_field = fields.iter().find(|f| f.name == "color").unwrap();
452        assert!(color_field.required);
453        match &color_field.kind {
454            FormFieldKind::SingleSelect(rs) => {
455                assert_eq!(rs.options.len(), 3);
456                assert_eq!(rs.options[0].value, "red");
457            }
458            _ => panic!("Expected SingleSelect"),
459        }
460
461        let tags_field = fields.iter().find(|f| f.name == "tags").unwrap();
462        match &tags_field.kind {
463            FormFieldKind::MultiSelect(ms) => {
464                assert_eq!(ms.options.len(), 3);
465                assert!(ms.selected.iter().all(|&s| !s));
466            }
467            _ => panic!("Expected MultiSelect"),
468        }
469    }
470
471    #[tokio::test(flavor = "current_thread")]
472    async fn confirm_produces_correct_json() {
473        LocalSet::new()
474            .run_until(async {
475                let (cx, mut peer) = test_connection().await;
476                let (responder, _rx) = peer.fake_elicitation(&cx).await;
477                let params = ElicitationParams {
478                    server_name: "test-server".to_string(),
479                    request: CreateElicitationRequestParams::FormElicitationParams {
480                        meta: None,
481                        message: "Test".to_string(),
482                        requested_schema: ElicitationSchema::builder()
483                            .optional_string("name")
484                            .optional_bool("approved", true)
485                            .optional_enum_schema(
486                                "color",
487                                EnumSchema::builder(vec!["red".into(), "green".into()])
488                                    .untitled()
489                                    .with_default("green")
490                                    .unwrap()
491                                    .build(),
492                            )
493                            .build()
494                            .unwrap(),
495                    },
496                };
497
498                let form = ElicitationForm::from_params(params, responder);
499                let response = form.confirm();
500
501                assert_eq!(response.action, ElicitationAction::Accept);
502                let content = response.content.unwrap();
503                assert_eq!(content["name"], "");
504                assert_eq!(content["approved"], true);
505                assert_eq!(content["color"], "green");
506            })
507            .await;
508    }
509
510    #[test]
511    fn esc_returns_cancel() {
512        let response = ElicitationForm::cancel();
513        assert_eq!(response.action, ElicitationAction::Cancel);
514        assert!(response.content.is_none());
515    }
516
517    #[test]
518    fn decline_returns_decline() {
519        let response = ElicitationForm::decline();
520        assert_eq!(response.action, ElicitationAction::Decline);
521        assert!(response.content.is_none());
522    }
523
524    #[test]
525    fn url_prompt_parses_host() {
526        let prompt = UrlPrompt::new(
527            "github".to_string(),
528            "el-1".to_string(),
529            "Authorize".to_string(),
530            "https://github.com/login/oauth".to_string(),
531        );
532        assert_eq!(prompt.host.as_deref(), Some("github.com"));
533        assert!(prompt.warnings.is_empty());
534        assert!(prompt.launch_error.is_none());
535    }
536
537    #[test]
538    fn url_prompt_warns_on_non_https() {
539        let prompt = UrlPrompt::new(
540            "test".to_string(),
541            "el-1".to_string(),
542            "Open this".to_string(),
543            "http://example.com/form".to_string(),
544        );
545        assert_eq!(prompt.warnings.len(), 1);
546        assert!(prompt.warnings[0].contains("HTTPS"));
547    }
548
549    #[test]
550    fn url_prompt_does_not_warn_on_localhost() {
551        let prompt = UrlPrompt::new(
552            "test".to_string(),
553            "el-1".to_string(),
554            "Local".to_string(),
555            "http://localhost:3000/auth".to_string(),
556        );
557        assert!(prompt.warnings.is_empty());
558    }
559
560    #[test]
561    fn url_prompt_warns_on_invalid_url() {
562        let prompt = UrlPrompt::new(
563            "test".to_string(),
564            "el-invalid".to_string(),
565            "Check this".to_string(),
566            "not a valid url".to_string(),
567        );
568        assert!(prompt.host.is_none());
569        assert!(
570            prompt.warnings.iter().any(|warning| warning.contains("could not be parsed")),
571            "invalid URLs should show an explicit warning"
572        );
573    }
574
575    #[test]
576    fn url_prompt_warns_on_punycode() {
577        let prompt = UrlPrompt::new(
578            "test".to_string(),
579            "el-1".to_string(),
580            "Phishing".to_string(),
581            "https://xn--e1afmkfd.xn--p1ai/".to_string(),
582        );
583        assert_eq!(prompt.warnings.len(), 1);
584        assert!(prompt.warnings[0].contains("punycode"));
585    }
586
587    #[test]
588    fn url_prompt_warns_on_punycode_and_non_https() {
589        let prompt = UrlPrompt::new(
590            "test".to_string(),
591            "el-1".to_string(),
592            "Both".to_string(),
593            "http://xn--e1afmkfd.xn--p1ai/".to_string(),
594        );
595        assert_eq!(prompt.warnings.len(), 2, "both warnings should be present");
596        assert!(prompt.warnings.iter().any(|w| w.contains("punycode")));
597        assert!(prompt.warnings.iter().any(|w| w.contains("HTTPS")));
598    }
599
600    fn url_params(server: &str, id: &str, url: &str) -> ElicitationParams {
601        ElicitationParams {
602            server_name: server.to_string(),
603            request: CreateElicitationRequestParams::UrlElicitationParams {
604                meta: None,
605                message: "Auth".to_string(),
606                url: url.to_string(),
607                elicitation_id: id.to_string(),
608            },
609        }
610    }
611
612    fn permission_like_params() -> ElicitationParams {
613        ElicitationParams {
614            server_name: "coding".to_string(),
615            request: CreateElicitationRequestParams::FormElicitationParams {
616                meta: None,
617                message: "Allow bash: rm -rf /tmp?".to_string(),
618                requested_schema: ElicitationSchema::builder()
619                    .required_enum_schema(
620                        "decision",
621                        EnumSchema::builder(vec!["allow".into(), "deny".into()])
622                            .untitled()
623                            .with_default("deny")
624                            .unwrap()
625                            .build(),
626                    )
627                    .build()
628                    .unwrap(),
629            },
630        }
631    }
632
633    #[tokio::test(flavor = "current_thread")]
634    async fn single_field_permission_like_form_submits_on_first_enter() {
635        LocalSet::new()
636            .run_until(async {
637                let (cx, mut peer) = test_connection().await;
638                let (responder, rx) = peer.fake_elicitation(&cx).await;
639                let mut form = ElicitationForm::from_params(permission_like_params(), responder);
640
641                let outcome =
642                    form.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
643                let messages = outcome.expect("enter should be handled");
644
645                assert!(messages.iter().any(|m| matches!(m, ElicitationMessage::Responded)));
646
647                let response = rx.await.expect("first enter should produce a response");
648                assert_eq!(response.action, ElicitationAction::Accept);
649                assert_eq!(response.content.unwrap()["decision"], "deny");
650            })
651            .await;
652    }
653
654    #[tokio::test(flavor = "current_thread")]
655    async fn single_field_permission_like_form_respects_default_deny() {
656        LocalSet::new()
657            .run_until(async {
658                let (cx, mut peer) = test_connection().await;
659                let (responder, _rx) = peer.fake_elicitation(&cx).await;
660                let form = ElicitationForm::from_params(permission_like_params(), responder);
661
662                let response = form.confirm();
663                assert_eq!(response.action, ElicitationAction::Accept);
664                assert_eq!(response.content.unwrap()["decision"], "deny");
665            })
666            .await;
667    }
668
669    #[tokio::test(flavor = "current_thread")]
670    async fn url_modal_enter_returns_accept_with_carried_id() {
671        LocalSet::new()
672            .run_until(async {
673                let opened_urls = Arc::new(Mutex::new(Vec::new()));
674                let opened_urls_for_opener = Arc::clone(&opened_urls);
675                let (cx, mut peer) = test_connection().await;
676                let (responder, rx) = peer.fake_elicitation(&cx).await;
677                let params = url_params("github", "el-123", "https://github.com/login/oauth");
678                let mut form = ElicitationForm::with_browser_opener(params, responder, move |url| {
679                    opened_urls_for_opener.lock().unwrap().push(url.to_string());
680                    Ok(())
681                });
682                let outcome =
683                    form.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
684                let messages = outcome.unwrap();
685
686                assert_eq!(opened_urls.lock().unwrap().as_slice(), ["https://github.com/login/oauth"]);
687                assert!(messages.iter().any(|m| matches!(m, ElicitationMessage::Responded)));
688                let opened = messages.iter().find_map(|m| match m {
689                    ElicitationMessage::UrlOpened { elicitation_id, server_name } => {
690                        Some((elicitation_id.clone(), server_name.clone()))
691                    }
692                    ElicitationMessage::Responded => None,
693                });
694                let (id, server) = opened.expect("UrlOpened message should be emitted");
695                assert_eq!(id, "el-123", "elicitation_id must come from request, not URL re-parsing");
696                assert_eq!(server, "github");
697
698                let response = rx.await.unwrap();
699                assert_eq!(response.action, ElicitationAction::Accept);
700                assert!(response.content.is_none());
701            })
702            .await;
703    }
704
705    #[tokio::test(flavor = "current_thread")]
706    async fn url_modal_launch_failure_keeps_modal_open_and_shows_error() {
707        LocalSet::new()
708            .run_until(async {
709                let (cx, mut peer) = test_connection().await;
710                let (responder, mut rx) = peer.fake_elicitation(&cx).await;
711                let params = url_params("github", "el-fail", "https://github.com/login/oauth");
712                let mut form = ElicitationForm::with_browser_opener(params, responder, |_| Err("boom".to_string()));
713
714                let outcome =
715                    form.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
716                let messages = outcome.expect("URL opener failure should still produce an event result");
717                assert!(messages.is_empty(), "modal should remain open on launch failure");
718                assert!(rx.try_recv().is_err(), "response should not be sent when browser launch fails");
719
720                let ElicitationUi::Url(prompt) = &form.ui else {
721                    panic!("expected URL prompt");
722                };
723                assert_eq!(prompt.launch_error.as_deref(), Some("Failed to open browser: boom"));
724            })
725            .await;
726    }
727
728    #[tokio::test(flavor = "current_thread")]
729    async fn url_modal_d_returns_decline() {
730        LocalSet::new()
731            .run_until(async {
732                let (cx, mut peer) = test_connection().await;
733                let (responder, rx) = peer.fake_elicitation(&cx).await;
734                let params = url_params("github", "el-456", "https://github.com/login/oauth");
735                let mut form = ElicitationForm::from_params(params, responder);
736                let outcome = form
737                    .on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Char('d'), tui::KeyModifiers::NONE)))
738                    .await;
739                let messages = outcome.unwrap();
740
741                assert!(messages.iter().any(|m| matches!(m, ElicitationMessage::Responded)));
742
743                let response = rx.await.unwrap();
744                assert_eq!(response.action, ElicitationAction::Decline);
745            })
746            .await;
747    }
748
749    #[tokio::test(flavor = "current_thread")]
750    async fn url_modal_esc_returns_cancel() {
751        LocalSet::new()
752            .run_until(async {
753                let (cx, mut peer) = test_connection().await;
754                let (responder, rx) = peer.fake_elicitation(&cx).await;
755                let params = url_params("github", "el-789", "https://github.com/login/oauth");
756                let mut form = ElicitationForm::from_params(params, responder);
757                let outcome =
758                    form.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Esc, tui::KeyModifiers::NONE))).await;
759                let messages = outcome.unwrap();
760
761                assert!(messages.iter().any(|m| matches!(m, ElicitationMessage::Responded)));
762
763                let response = rx.await.unwrap();
764                assert_eq!(response.action, ElicitationAction::Cancel);
765            })
766            .await;
767    }
768
769    #[tokio::test(flavor = "current_thread")]
770    async fn form_modal_esc_returns_cancel() {
771        LocalSet::new()
772            .run_until(async {
773                let (cx, mut peer) = test_connection().await;
774                let (responder, rx) = peer.fake_elicitation(&cx).await;
775                let params = ElicitationParams {
776                    server_name: "test".to_string(),
777                    request: CreateElicitationRequestParams::FormElicitationParams {
778                        meta: None,
779                        message: "Test".to_string(),
780                        requested_schema: ElicitationSchema::builder().build().unwrap(),
781                    },
782                };
783                let mut form = ElicitationForm::from_params(params, responder);
784                let outcome =
785                    form.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Esc, tui::KeyModifiers::NONE))).await;
786                let messages = outcome.unwrap();
787
788                assert!(messages.iter().any(|m| matches!(m, ElicitationMessage::Responded)));
789
790                let response = rx.await.unwrap();
791                assert_eq!(response.action, ElicitationAction::Cancel);
792            })
793            .await;
794    }
795
796    #[test]
797    fn one_of_string_produces_single_select() {
798        let schema: ElicitationSchema = serde_json::from_value(serde_json::json!({
799            "type": "object",
800            "properties": {
801                "size": {
802                    "type": "string",
803                    "oneOf": [
804                        { "const": "s", "title": "Small" },
805                        { "const": "m", "title": "Medium" },
806                        { "const": "l", "title": "Large" }
807                    ]
808                }
809            }
810        }))
811        .unwrap();
812        let fields = parse_schema(&schema);
813        assert_eq!(fields.len(), 1);
814        match &fields[0].kind {
815            FormFieldKind::SingleSelect(rs) => {
816                assert_eq!(rs.options.len(), 3);
817                assert_eq!(rs.options[0].title, "Small");
818                assert_eq!(rs.options[0].value, "s");
819            }
820            _ => panic!("Expected SingleSelect"),
821        }
822    }
823
824    #[test]
825    fn empty_schema_produces_no_fields() {
826        let schema = ElicitationSchema::new(BTreeMap::new());
827        let fields = parse_schema(&schema);
828        assert!(fields.is_empty());
829    }
830
831    #[test]
832    fn url_modal_renders_server_name_and_url() {
833        use tui::testing::render_component;
834
835        let prompt = UrlPrompt::new(
836            "github".to_string(),
837            "el-1".to_string(),
838            "Authorize GitHub".to_string(),
839            "https://github.com/login/oauth".to_string(),
840        );
841        let ui = ElicitationUi::Url(prompt);
842        let mut form = ElicitationForm { ui, browser_opener: Arc::new(default_browser_opener), responder: None };
843
844        let lines = render_component(|ctx| form.render(ctx), 80, 20).get_lines();
845        let text: String = lines.join("\n");
846        assert!(text.contains("github"), "should show server name");
847        assert!(text.contains("https://github.com/login/oauth"), "should show full URL");
848        assert!(text.contains("github.com"), "should show host");
849        assert!(text.contains("Enter to open URL"), "should show footer hint");
850    }
851}