Skip to main content

wisp/components/
elicitation_form.rs

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