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