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