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