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 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
261impl 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}