Skip to main content

wisp/components/
elicitation_form.rs

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