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