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