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    /// If this form is showing the URL prompt that `params` refers to, accept
245    /// it and consume the responder. Returns true iff the form was answered.
246    pub fn accept_url_complete(&mut self, params: &UrlElicitationCompleteParams) -> bool {
247        let ElicitationUi::Url(prompt) = &self.ui else {
248            return false;
249        };
250        if prompt.server_name != params.server_name || prompt.elicitation_id != params.elicitation_id {
251            return false;
252        }
253        let response = self.confirm();
254        if let Some(responder) = self.responder.take() {
255            let _ = responder.respond(response);
256        }
257        true
258    }
259}
260
261/// A form dropped without being answered (dismissed, replaced, or the app shut
262/// down) still owes the requesting agent a response, or it would block forever.
263impl Drop for ElicitationForm {
264    fn drop(&mut self) {
265        if let Some(responder) = self.responder.take() {
266            let _ = responder.respond(Self::cancel());
267        }
268    }
269}
270
271pub fn render_url_prompt(prompt: &UrlPrompt, ctx: &ViewContext) -> Frame {
272    use tui::{Line, Style};
273
274    let mut lines = Vec::new();
275    let text_primary = ctx.theme.text_primary();
276    let text_secondary = ctx.theme.text_secondary();
277    let warning_color = ctx.theme.warning();
278    lines.push(Line::default());
279    lines.push(Line::with_style(&prompt.message, Style::fg(text_primary)));
280
281    if let Some(ref host) = prompt.host {
282        lines.push(Line::with_style(format!("Host: {host}"), Style::fg(text_secondary)));
283    }
284
285    if !prompt.warnings.is_empty() {
286        lines.push(Line::default());
287        for warning in &prompt.warnings {
288            lines.push(Line::styled(warning, warning_color));
289        }
290    }
291
292    if let Some(ref message) = prompt.copy_message {
293        lines.push(Line::default());
294        lines.push(Line::with_style(message, Style::fg(text_secondary)));
295    }
296
297    if let Some(ref error) = prompt.launch_error {
298        lines.push(Line::default());
299        lines.push(Line::styled(error, ctx.theme.error()));
300    }
301
302    Frame::new(lines)
303}
304
305fn is_local_http_url(url: &url::Url) -> bool {
306    if url.scheme() != "http" {
307        return false;
308    }
309
310    matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
311}
312
313fn default_browser_opener(url: &str) -> Result<(), UrlHandlerError> {
314    #[cfg(target_os = "macos")]
315    {
316        let status = Command::new("open")
317            .arg(url)
318            .status()
319            .map_err(|source| UrlHandlerError::Spawn { command: "open".to_string(), source })?;
320        return status
321            .success()
322            .then_some(())
323            .ok_or_else(|| UrlHandlerError::BadExit { command: "open".to_string(), status: status.to_string() });
324    }
325
326    #[cfg(target_os = "linux")]
327    {
328        let status = Command::new("xdg-open")
329            .arg(url)
330            .status()
331            .map_err(|source| UrlHandlerError::Spawn { command: "xdg-open".to_string(), source })?;
332        return status
333            .success()
334            .then_some(())
335            .ok_or_else(|| UrlHandlerError::BadExit { command: "xdg-open".to_string(), status: status.to_string() });
336    }
337
338    #[cfg(target_os = "windows")]
339    {
340        let status = Command::new("cmd")
341            .args(["/C", "start", url])
342            .status()
343            .map_err(|source| UrlHandlerError::Spawn { command: "start".to_string(), source })?;
344        return status
345            .success()
346            .then_some(())
347            .ok_or_else(|| UrlHandlerError::BadExit { command: "start".to_string(), status: status.to_string() });
348    }
349
350    #[allow(unreachable_code)]
351    Err(UrlHandlerError::Unsupported("Unsupported platform for opening URLs"))
352}
353
354fn default_clipboard_writer(text: &str) -> Result<(), UrlHandlerError> {
355    #[cfg(target_os = "macos")]
356    {
357        return cmd("pbcopy", &[], text);
358    }
359
360    #[cfg(target_os = "linux")]
361    {
362        return cmd("wl-copy", &[], text)
363            .or_else(|_| cmd("xclip", &["-selection", "clipboard"], text))
364            .or_else(|_| cmd("xsel", &["--clipboard", "--input"], text));
365    }
366
367    #[cfg(target_os = "windows")]
368    {
369        return cmd("clip", &[], text);
370    }
371
372    #[allow(unreachable_code)]
373    Err(UrlHandlerError::Unsupported("Unsupported platform for copying URLs"))
374}
375
376fn cmd(command: &str, args: &[&str], text: &str) -> Result<(), UrlHandlerError> {
377    let mut child = Command::new(command)
378        .args(args)
379        .stdin(Stdio::piped())
380        .spawn()
381        .map_err(|source| UrlHandlerError::Spawn { command: command.to_string(), source })?;
382    child
383        .stdin
384        .as_mut()
385        .ok_or_else(|| UrlHandlerError::NoStdin { command: command.to_string() })?
386        .write_all(text.as_bytes())
387        .map_err(|source| UrlHandlerError::Write { command: command.to_string(), source })?;
388    let status = child.wait().map_err(|source| UrlHandlerError::Write { command: command.to_string(), source })?;
389    status
390        .success()
391        .then_some(())
392        .ok_or_else(|| UrlHandlerError::BadExit { command: command.to_string(), status: status.to_string() })
393}
394
395fn parse_schema(schema: &ElicitationSchema) -> Vec<FormField> {
396    let required = schema.required.as_deref().unwrap_or(&[]);
397    schema
398        .properties
399        .iter()
400        .map(|(name, prop)| {
401            let (title, description) = extract_metadata(prop);
402            FormField {
403                name: name.clone(),
404                label: title.unwrap_or_else(|| name.clone()),
405                description,
406                required: required.iter().any(|r| r == name),
407                kind: parse_field_kind(prop),
408            }
409        })
410        .collect()
411}
412
413fn parse_field_kind(prop: &PrimitiveSchema) -> FormFieldKind {
414    match prop {
415        PrimitiveSchema::Boolean(b) => FormFieldKind::Boolean(Checkbox::new(b.default.unwrap_or(false))),
416        PrimitiveSchema::Integer(_) => FormFieldKind::Number(NumberField::new(String::new(), true)),
417        PrimitiveSchema::Number(_) => FormFieldKind::Number(NumberField::new(String::new(), false)),
418        PrimitiveSchema::String(_) => FormFieldKind::Text(TextField::new(String::new())),
419        PrimitiveSchema::Enum(e) => parse_enum_field(e),
420    }
421}
422
423fn parse_enum_field(e: &EnumSchema) -> FormFieldKind {
424    match e {
425        EnumSchema::Single(s) => match s {
426            SingleSelectEnumSchema::Untitled(u) => {
427                let options = options_from_strings(&u.enum_);
428                let default_idx =
429                    u.default.as_ref().and_then(|d| options.iter().position(|o| o.value == *d)).unwrap_or(0);
430                FormFieldKind::SingleSelect(RadioSelect::new(options, default_idx))
431            }
432            SingleSelectEnumSchema::Titled(t) => {
433                let options = options_from_const_titles(&t.one_of);
434                let default_idx =
435                    t.default.as_ref().and_then(|d| options.iter().position(|o| o.value == *d)).unwrap_or(0);
436                FormFieldKind::SingleSelect(RadioSelect::new(options, default_idx))
437            }
438        },
439        EnumSchema::Multi(m) => match m {
440            MultiSelectEnumSchema::Untitled(u) => {
441                let options = options_from_strings(&u.items.enum_);
442                let defaults = u.default.as_deref().unwrap_or(&[]);
443                let selected: Vec<bool> = options.iter().map(|o| defaults.contains(&o.value)).collect();
444                FormFieldKind::MultiSelect(MultiSelect::new(options, selected))
445            }
446            MultiSelectEnumSchema::Titled(t) => {
447                let options = options_from_const_titles(&t.items.any_of);
448                let defaults = t.default.as_deref().unwrap_or(&[]);
449                let selected: Vec<bool> = options.iter().map(|o| defaults.contains(&o.value)).collect();
450                FormFieldKind::MultiSelect(MultiSelect::new(options, selected))
451            }
452        },
453        EnumSchema::Legacy(l) => {
454            let options = options_from_strings(&l.enum_);
455            FormFieldKind::SingleSelect(RadioSelect::new(options, 0))
456        }
457    }
458}
459
460fn extract_metadata(prop: &PrimitiveSchema) -> (Option<String>, Option<String>) {
461    match prop {
462        PrimitiveSchema::String(s) => {
463            (s.title.as_ref().map(ToString::to_string), s.description.as_ref().map(ToString::to_string))
464        }
465        PrimitiveSchema::Number(n) => {
466            (n.title.as_ref().map(ToString::to_string), n.description.as_ref().map(ToString::to_string))
467        }
468        PrimitiveSchema::Integer(i) => {
469            (i.title.as_ref().map(ToString::to_string), i.description.as_ref().map(ToString::to_string))
470        }
471        PrimitiveSchema::Boolean(b) => {
472            (b.title.as_ref().map(ToString::to_string), b.description.as_ref().map(ToString::to_string))
473        }
474        PrimitiveSchema::Enum(e) => extract_enum_metadata(e),
475    }
476}
477
478fn extract_enum_metadata(e: &EnumSchema) -> (Option<String>, Option<String>) {
479    match e {
480        EnumSchema::Single(s) => match s {
481            SingleSelectEnumSchema::Untitled(u) => {
482                (u.title.as_ref().map(ToString::to_string), u.description.as_ref().map(ToString::to_string))
483            }
484            SingleSelectEnumSchema::Titled(t) => {
485                (t.title.as_ref().map(ToString::to_string), t.description.as_ref().map(ToString::to_string))
486            }
487        },
488        EnumSchema::Multi(m) => match m {
489            MultiSelectEnumSchema::Untitled(u) => {
490                (u.title.as_ref().map(ToString::to_string), u.description.as_ref().map(ToString::to_string))
491            }
492            MultiSelectEnumSchema::Titled(t) => {
493                (t.title.as_ref().map(ToString::to_string), t.description.as_ref().map(ToString::to_string))
494            }
495        },
496        EnumSchema::Legacy(l) => {
497            (l.title.as_ref().map(ToString::to_string), l.description.as_ref().map(ToString::to_string))
498        }
499    }
500}
501
502fn options_from_strings(values: &[String]) -> Vec<SelectOption> {
503    values.iter().map(|s| SelectOption { value: s.clone(), title: s.clone(), description: None }).collect()
504}
505
506fn options_from_const_titles(items: &[ConstTitle]) -> Vec<SelectOption> {
507    items
508        .iter()
509        .map(|ct| SelectOption { value: ct.const_.clone(), title: ct.title.clone(), description: None })
510        .collect()
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::test_helpers::{elicitation_params, key};
517    use acp_utils::EnumSchema;
518    use acp_utils::testing::test_connection;
519    use std::collections::BTreeMap;
520    use std::sync::Arc;
521    use tokio::task::LocalSet;
522
523    fn test_schema() -> ElicitationSchema {
524        serde_json::from_value(serde_json::json!({
525            "type": "object",
526            "properties": {
527                "name": {
528                    "type": "string",
529                    "title": "Your Name",
530                    "description": "Enter your full name"
531                },
532                "age": {
533                    "type": "integer",
534                    "title": "Age",
535                    "minimum": 0,
536                    "maximum": 150
537                },
538                "rating": {
539                    "type": "number",
540                    "title": "Rating"
541                },
542                "approved": {
543                    "type": "boolean",
544                    "title": "Approved",
545                    "default": true
546                },
547                "color": {
548                    "type": "string",
549                    "title": "Favorite Color",
550                    "enum": ["red", "green", "blue"]
551                },
552                "tags": {
553                    "type": "array",
554                    "title": "Tags",
555                    "items": {
556                        "type": "string",
557                        "enum": ["fast", "reliable", "cheap"]
558                    }
559                }
560            },
561            "required": ["name", "color"]
562        }))
563        .unwrap()
564    }
565
566    #[test]
567    fn parse_schema_extracts_all_field_types() {
568        let schema = test_schema();
569        let fields = parse_schema(&schema);
570        assert_eq!(fields.len(), 6);
571
572        let name_field = fields.iter().find(|f| f.name == "name").unwrap();
573        assert_eq!(name_field.label, "Your Name");
574        assert!(name_field.required);
575        assert!(matches!(name_field.kind, FormFieldKind::Text(_)));
576
577        let age_field = fields.iter().find(|f| f.name == "age").unwrap();
578        match &age_field.kind {
579            FormFieldKind::Number(nf) => assert!(nf.integer_only),
580            _ => panic!("Expected Number (integer)"),
581        }
582
583        let bool_field = fields.iter().find(|f| f.name == "approved").unwrap();
584        match &bool_field.kind {
585            FormFieldKind::Boolean(cb) => assert!(cb.checked),
586            _ => panic!("Expected Boolean"),
587        }
588
589        let color_field = fields.iter().find(|f| f.name == "color").unwrap();
590        assert!(color_field.required);
591        match &color_field.kind {
592            FormFieldKind::SingleSelect(rs) => {
593                assert_eq!(rs.options.len(), 3);
594                assert_eq!(rs.options[0].value, "red");
595            }
596            _ => panic!("Expected SingleSelect"),
597        }
598
599        let tags_field = fields.iter().find(|f| f.name == "tags").unwrap();
600        match &tags_field.kind {
601            FormFieldKind::MultiSelect(ms) => {
602                assert_eq!(ms.options.len(), 3);
603                assert!(ms.selected.iter().all(|&s| !s));
604            }
605            _ => panic!("Expected MultiSelect"),
606        }
607    }
608
609    #[tokio::test(flavor = "current_thread")]
610    async fn confirm_produces_correct_json() {
611        LocalSet::new()
612            .run_until(async {
613                let (cx, mut peer) = test_connection().await;
614                let (responder, _rx) = peer.fake_elicitation(&cx).await;
615                let schema = ElicitationSchema::builder()
616                    .optional_string("name")
617                    .optional_bool("approved", true)
618                    .optional_enum_schema(
619                        "color",
620                        EnumSchema::builder(vec!["red".into(), "green".into()])
621                            .untitled()
622                            .with_default("green")
623                            .unwrap()
624                            .build(),
625                    )
626                    .build()
627                    .unwrap();
628                let params = elicitation_params("test-server", "Test", schema);
629
630                let form = ElicitationForm::from_params(params, responder);
631                let response = form.confirm();
632
633                assert_eq!(response.action, ElicitationAction::Accept);
634                let content = response.content.unwrap();
635                assert_eq!(content["name"], "");
636                assert_eq!(content["approved"], true);
637                assert_eq!(content["color"], "green");
638            })
639            .await;
640    }
641
642    #[tokio::test(flavor = "current_thread")]
643    async fn dropping_unanswered_form_responds_cancel() {
644        LocalSet::new()
645            .run_until(async {
646                let (cx, mut peer) = test_connection().await;
647                let (responder, rx) = peer.fake_elicitation(&cx).await;
648                let params = elicitation_params("test-server", "Test", ElicitationSchema::builder().build().unwrap());
649
650                drop(ElicitationForm::from_params(params, responder));
651
652                let response = rx.await.expect("dropped form must still answer the requester");
653                assert_eq!(response.action, ElicitationAction::Cancel);
654                assert!(response.content.is_none());
655            })
656            .await;
657    }
658
659    #[test]
660    fn esc_returns_cancel() {
661        let response = ElicitationForm::cancel();
662        assert_eq!(response.action, ElicitationAction::Cancel);
663        assert!(response.content.is_none());
664    }
665
666    #[test]
667    fn url_prompt_parses_host() {
668        let prompt = UrlPrompt::new(
669            "github".to_string(),
670            "el-1".to_string(),
671            "Authorize".to_string(),
672            "https://github.com/login/oauth".to_string(),
673        );
674        assert_eq!(prompt.host.as_deref(), Some("github.com"));
675        assert!(prompt.warnings.is_empty());
676        assert!(prompt.launch_error.is_none());
677    }
678
679    #[test]
680    fn url_prompt_warns_on_non_https() {
681        let prompt = UrlPrompt::new(
682            "test".to_string(),
683            "el-1".to_string(),
684            "Open this".to_string(),
685            "http://example.com/form".to_string(),
686        );
687        assert_eq!(prompt.warnings.len(), 1);
688        assert!(prompt.warnings[0].contains("HTTPS"));
689    }
690
691    #[test]
692    fn url_prompt_does_not_warn_on_localhost() {
693        let prompt = UrlPrompt::new(
694            "test".to_string(),
695            "el-1".to_string(),
696            "Local".to_string(),
697            "http://localhost:3000/auth".to_string(),
698        );
699        assert!(prompt.warnings.is_empty());
700    }
701
702    #[test]
703    fn url_prompt_warns_on_invalid_url() {
704        let prompt = UrlPrompt::new(
705            "test".to_string(),
706            "el-invalid".to_string(),
707            "Check this".to_string(),
708            "not a valid url".to_string(),
709        );
710        assert!(prompt.host.is_none());
711        assert!(
712            prompt.warnings.iter().any(|warning| warning.contains("could not be parsed")),
713            "invalid URLs should show an explicit warning"
714        );
715    }
716
717    #[test]
718    fn url_prompt_warns_on_punycode() {
719        let prompt = UrlPrompt::new(
720            "test".to_string(),
721            "el-1".to_string(),
722            "Phishing".to_string(),
723            "https://xn--e1afmkfd.xn--p1ai/".to_string(),
724        );
725        assert_eq!(prompt.warnings.len(), 1);
726        assert!(prompt.warnings[0].contains("punycode"));
727    }
728
729    #[test]
730    fn url_prompt_warns_on_punycode_and_non_https() {
731        let prompt = UrlPrompt::new(
732            "test".to_string(),
733            "el-1".to_string(),
734            "Both".to_string(),
735            "http://xn--e1afmkfd.xn--p1ai/".to_string(),
736        );
737        assert_eq!(prompt.warnings.len(), 2, "both warnings should be present");
738        assert!(prompt.warnings.iter().any(|w| w.contains("punycode")));
739        assert!(prompt.warnings.iter().any(|w| w.contains("HTTPS")));
740    }
741
742    fn permission_like_params() -> ElicitationParams {
743        let schema = ElicitationSchema::builder()
744            .required_enum_schema(
745                "decision",
746                EnumSchema::builder(vec!["allow".into(), "deny".into()])
747                    .untitled()
748                    .with_default("deny")
749                    .unwrap()
750                    .build(),
751            )
752            .build()
753            .unwrap();
754        elicitation_params("coding", "Allow bash: rm -rf /tmp?", schema)
755    }
756
757    #[tokio::test(flavor = "current_thread")]
758    async fn single_field_permission_like_form_submits_on_first_enter() {
759        LocalSet::new()
760            .run_until(async {
761                let (cx, mut peer) = test_connection().await;
762                let (responder, rx) = peer.fake_elicitation(&cx).await;
763                let mut form = ElicitationForm::from_params(permission_like_params(), responder);
764
765                let outcome = form.on_event(&key(tui::KeyCode::Enter)).await;
766                let messages = outcome.expect("enter should be handled");
767
768                assert!(messages.iter().any(|m| matches!(m, ElicitationMessage::Responded)));
769
770                let response = rx.await.expect("first enter should produce a response");
771                assert_eq!(response.action, ElicitationAction::Accept);
772                assert_eq!(response.content.unwrap()["decision"], "deny");
773            })
774            .await;
775    }
776
777    #[tokio::test(flavor = "current_thread")]
778    async fn single_field_permission_like_form_respects_default_deny() {
779        LocalSet::new()
780            .run_until(async {
781                let (cx, mut peer) = test_connection().await;
782                let (responder, _rx) = peer.fake_elicitation(&cx).await;
783                let form = ElicitationForm::from_params(permission_like_params(), responder);
784
785                let response = form.confirm();
786                assert_eq!(response.action, ElicitationAction::Accept);
787                assert_eq!(response.content.unwrap()["decision"], "deny");
788            })
789            .await;
790    }
791
792    #[tokio::test(flavor = "current_thread")]
793    async fn form_modal_esc_returns_cancel() {
794        LocalSet::new()
795            .run_until(async {
796                let (cx, mut peer) = test_connection().await;
797                let (responder, rx) = peer.fake_elicitation(&cx).await;
798                let params = elicitation_params("test", "Test", ElicitationSchema::builder().build().unwrap());
799                let mut form = ElicitationForm::from_params(params, responder);
800                let outcome = form.on_event(&key(tui::KeyCode::Esc)).await;
801                let messages = outcome.unwrap();
802
803                assert!(messages.iter().any(|m| matches!(m, ElicitationMessage::Responded)));
804
805                let response = rx.await.unwrap();
806                assert_eq!(response.action, ElicitationAction::Cancel);
807            })
808            .await;
809    }
810
811    #[test]
812    fn one_of_string_produces_single_select() {
813        let schema: ElicitationSchema = serde_json::from_value(serde_json::json!({
814            "type": "object",
815            "properties": {
816                "size": {
817                    "type": "string",
818                    "oneOf": [
819                        { "const": "s", "title": "Small" },
820                        { "const": "m", "title": "Medium" },
821                        { "const": "l", "title": "Large" }
822                    ]
823                }
824            }
825        }))
826        .unwrap();
827        let fields = parse_schema(&schema);
828        assert_eq!(fields.len(), 1);
829        match &fields[0].kind {
830            FormFieldKind::SingleSelect(rs) => {
831                assert_eq!(rs.options.len(), 3);
832                assert_eq!(rs.options[0].title, "Small");
833                assert_eq!(rs.options[0].value, "s");
834            }
835            _ => panic!("Expected SingleSelect"),
836        }
837    }
838
839    #[test]
840    fn empty_schema_produces_no_fields() {
841        let schema = ElicitationSchema::new(BTreeMap::new());
842        let fields = parse_schema(&schema);
843        assert!(fields.is_empty());
844    }
845
846    #[test]
847    fn url_modal_renders_server_name_without_url_or_controls() {
848        use tui::testing::render_component;
849
850        let prompt = UrlPrompt::new(
851            "github".to_string(),
852            "el-1".to_string(),
853            "Authorize GitHub".to_string(),
854            "https://github.com/login/oauth".to_string(),
855        );
856        let ui = ElicitationUi::Url(prompt);
857        let mut form = ElicitationForm {
858            ui,
859            browser_opener: Arc::new(default_browser_opener),
860            clipboard_writer: Arc::new(default_clipboard_writer),
861            responder: None,
862        };
863
864        let lines = render_component(|ctx| form.render(ctx), 80, 20).get_lines();
865        let text: String = lines.join("\n");
866        assert!(text.contains("github"), "should show server name");
867        assert!(text.contains("Authorize GitHub"), "should show request message");
868        assert!(text.contains("github.com"), "should show host");
869    }
870}