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