Skip to main content

wisp/components/
elicitation_form.rs

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