Skip to repository content1716 lines · 64.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:06:22.369Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
elicitation.rs
1use acp_thread::{Elicitation, ElicitationEntryId, ElicitationStatus};
2use agent_client_protocol::schema::v1 as acp;
3use collections::{HashMap, HashSet};
4use component::{Component, ComponentScope, example_group_with_title, single_example};
5use editor::Editor;
6use futures::channel::oneshot;
7use gpui::{AnyElement, App, Div, Empty, Entity, Hsla, SharedString, Window, div};
8use std::collections::BTreeMap;
9use std::rc::Rc;
10use ui::{
11 Button, Checkbox, Color, Icon, IconName, IconSize, Indicator, Label, LabelSize, ToggleState,
12 prelude::*,
13};
14
15#[derive(Clone)]
16struct ElicitationOption {
17 value: String,
18 label: SharedString,
19 description: Option<SharedString>,
20}
21
22enum ElicitationFieldState {
23 Text(Entity<Editor>),
24 Boolean(bool),
25 SingleSelect { value: Option<String> },
26 MultiSelect(HashSet<String>),
27}
28
29pub(crate) struct ElicitationFormState {
30 fields: HashMap<String, ElicitationFieldState>,
31 field_errors: HashMap<String, SharedString>,
32}
33
34impl ElicitationFormState {
35 pub(crate) fn new(schema: &acp::ElicitationSchema, window: &mut Window, cx: &mut App) -> Self {
36 let required = schema.required.as_deref().unwrap_or_default();
37 let mut fields = HashMap::default();
38
39 for (name, property) in &schema.properties {
40 let is_required = required.iter().any(|required| required == name);
41 let field = match property {
42 acp::ElicitationPropertySchema::String(schema) => {
43 let options = single_select_options(schema);
44 if options.is_empty() {
45 let editor = cx.new(|cx| {
46 let mut editor = Editor::single_line(window, cx);
47 if let Some(default) = &schema.default {
48 editor.set_text(default.clone(), window, cx);
49 }
50 editor
51 });
52 ElicitationFieldState::Text(editor)
53 } else {
54 let value = single_select_default_value(schema, &options).or_else(|| {
55 is_required
56 .then(|| options.first().map(|option| option.value.clone()))
57 .flatten()
58 });
59 ElicitationFieldState::SingleSelect { value }
60 }
61 }
62 acp::ElicitationPropertySchema::Number(schema) => {
63 let editor = cx.new(|cx| {
64 let mut editor = Editor::single_line(window, cx);
65 if let Some(default) = schema.default {
66 editor.set_text(default.to_string(), window, cx);
67 }
68 editor
69 });
70 ElicitationFieldState::Text(editor)
71 }
72 acp::ElicitationPropertySchema::Integer(schema) => {
73 let editor = cx.new(|cx| {
74 let mut editor = Editor::single_line(window, cx);
75 if let Some(default) = schema.default {
76 editor.set_text(default.to_string(), window, cx);
77 }
78 editor
79 });
80 ElicitationFieldState::Text(editor)
81 }
82 acp::ElicitationPropertySchema::Boolean(schema) => {
83 ElicitationFieldState::Boolean(schema.default.unwrap_or(false))
84 }
85 acp::ElicitationPropertySchema::Array(schema) => {
86 ElicitationFieldState::MultiSelect(
87 schema
88 .default
89 .clone()
90 .unwrap_or_default()
91 .into_iter()
92 .collect(),
93 )
94 }
95 _ => continue,
96 };
97 fields.insert(name.clone(), field);
98 }
99
100 Self {
101 fields,
102 field_errors: HashMap::default(),
103 }
104 }
105
106 pub(crate) fn collect(
107 &self,
108 schema: &acp::ElicitationSchema,
109 cx: &App,
110 ) -> Result<BTreeMap<String, acp::ElicitationContentValue>, HashMap<String, SharedString>> {
111 let required = schema.required.as_deref().unwrap_or_default();
112 let mut content = BTreeMap::new();
113 let mut errors = HashMap::default();
114
115 for (name, property) in &schema.properties {
116 let is_required = required.iter().any(|required| required == name);
117 let Some(field) = self.fields.get(name) else {
118 continue;
119 };
120
121 let field_content = match (property, field) {
122 (
123 acp::ElicitationPropertySchema::String(schema),
124 ElicitationFieldState::Text(editor),
125 ) => {
126 let value = editor.read(cx).text(cx).to_string();
127 if value.is_empty() {
128 if is_required {
129 Err(format!("{} is required", property_title(name, property)).into())
130 } else {
131 Ok(None)
132 }
133 } else {
134 validate_string_value(property_title(name, property), schema, &value)
135 .map(|()| Some(value.into()))
136 }
137 }
138 (
139 acp::ElicitationPropertySchema::String(schema),
140 ElicitationFieldState::SingleSelect { value },
141 ) => {
142 if let Some(value) = value {
143 validate_single_select_value(property_title(name, property), schema, value)
144 .and_then(|()| {
145 validate_string_value(property_title(name, property), schema, value)
146 })
147 .map(|()| Some(value.clone().into()))
148 } else if is_required {
149 Err(format!("{} is required", property_title(name, property)).into())
150 } else {
151 Ok(None)
152 }
153 }
154 (
155 acp::ElicitationPropertySchema::Number(schema),
156 ElicitationFieldState::Text(editor),
157 ) => {
158 let value = editor.read(cx).text(cx).trim().to_string();
159 if value.is_empty() {
160 if is_required {
161 Err(format!("{} is required", property_title(name, property)).into())
162 } else {
163 Ok(None)
164 }
165 } else {
166 validate_number_value(property_title(name, property), schema, &value)
167 .map(|parsed| Some(parsed.into()))
168 }
169 }
170 (
171 acp::ElicitationPropertySchema::Integer(schema),
172 ElicitationFieldState::Text(editor),
173 ) => {
174 let value = editor.read(cx).text(cx).trim().to_string();
175 if value.is_empty() {
176 if is_required {
177 Err(format!("{} is required", property_title(name, property)).into())
178 } else {
179 Ok(None)
180 }
181 } else {
182 validate_integer_value(property_title(name, property), schema, &value)
183 .map(|parsed| Some(parsed.into()))
184 }
185 }
186 (
187 acp::ElicitationPropertySchema::Boolean(schema),
188 ElicitationFieldState::Boolean(value),
189 ) => {
190 if is_required || *value || schema.default.is_some() {
191 Ok(Some((*value).into()))
192 } else {
193 Ok(None)
194 }
195 }
196 (
197 acp::ElicitationPropertySchema::Array(schema),
198 ElicitationFieldState::MultiSelect(selected),
199 ) => {
200 let mut values = multi_select_options(schema)
201 .into_iter()
202 .filter_map(|option| {
203 selected.contains(&option.value).then_some(option.value)
204 })
205 .collect::<Vec<_>>();
206 values.sort();
207 if values.is_empty() && !is_required {
208 Ok(None)
209 } else if schema
210 .min_items
211 .is_some_and(|min_items| values.len() < min_items as usize)
212 {
213 Err(
214 format!("{} needs more selections", property_title(name, property))
215 .into(),
216 )
217 } else if schema
218 .max_items
219 .is_some_and(|max_items| values.len() > max_items as usize)
220 {
221 Err(
222 format!("{} has too many selections", property_title(name, property))
223 .into(),
224 )
225 } else {
226 Ok(Some(values.into()))
227 }
228 }
229 _ => Ok(None),
230 };
231
232 match field_content {
233 Ok(Some(value)) => {
234 content.insert(name.clone(), value);
235 }
236 Ok(None) => {}
237 Err(error) => {
238 errors.insert(name.clone(), error);
239 }
240 }
241 }
242
243 if errors.is_empty() {
244 Ok(content)
245 } else {
246 Err(errors)
247 }
248 }
249
250 pub(crate) fn set_errors(&mut self, errors: HashMap<String, SharedString>) {
251 self.field_errors = errors;
252 }
253
254 pub(crate) fn set_field_error(
255 &mut self,
256 field_name: impl Into<String>,
257 error: impl Into<SharedString>,
258 ) {
259 self.field_errors.insert(field_name.into(), error.into());
260 }
261
262 pub(crate) fn set_boolean(&mut self, field_name: &str, value: bool) {
263 if let Some(ElicitationFieldState::Boolean(field)) = self.fields.get_mut(field_name) {
264 *field = value;
265 self.field_errors.remove(field_name);
266 }
267 }
268
269 pub(crate) fn set_single_select(&mut self, field_name: &str, value: String) {
270 if let Some(ElicitationFieldState::SingleSelect { value: selected }) =
271 self.fields.get_mut(field_name)
272 {
273 *selected = Some(value);
274 self.field_errors.remove(field_name);
275 }
276 }
277
278 pub(crate) fn set_multi_select(&mut self, field_name: &str, value: String, selected: bool) {
279 if let Some(ElicitationFieldState::MultiSelect(values)) = self.fields.get_mut(field_name) {
280 if selected {
281 values.insert(value);
282 } else {
283 values.remove(&value);
284 }
285 self.field_errors.remove(field_name);
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use gpui::TestAppContext;
294
295 #[test]
296 fn string_validation_rejects_email_format_mismatch() {
297 let schema = acp::StringPropertySchema::email();
298
299 validate_string_value("Email".into(), &schema, "user@example.com")
300 .expect("valid email should be accepted");
301 assert_eq!(
302 validate_string_value("Email".into(), &schema, "not-an-email")
303 .expect_err("invalid email should be rejected")
304 .to_string(),
305 "Email must be an email address"
306 );
307 }
308
309 #[test]
310 fn string_validation_rejects_pattern_mismatch() {
311 let schema = acp::StringPropertySchema::new().pattern("^prod-[0-9]+$");
312
313 validate_string_value("Environment".into(), &schema, "prod-42")
314 .expect("matching pattern should be accepted");
315 assert_eq!(
316 validate_string_value("Environment".into(), &schema, "dev-42")
317 .expect_err("pattern mismatch should be rejected")
318 .to_string(),
319 "Environment does not match the requested pattern"
320 );
321 }
322
323 #[test]
324 fn number_validation_rejects_non_finite_values() {
325 let schema = acp::NumberPropertySchema::new();
326
327 assert_eq!(
328 validate_number_value("Amount".into(), &schema, "42.5")
329 .expect("finite number should be accepted"),
330 42.5
331 );
332
333 for value in ["NaN", "inf", "-inf", "1e309"] {
334 assert_eq!(
335 validate_number_value("Amount".into(), &schema, value)
336 .expect_err("non-finite number should be rejected")
337 .to_string(),
338 "Amount must be a finite number"
339 );
340 }
341 }
342
343 #[test]
344 fn should_render_pending_and_accepted_url_elicitations() {
345 let pending = Elicitation {
346 id: ElicitationEntryId("pending".into()),
347 request: acp::CreateElicitationRequest::new(
348 acp::ElicitationFormMode::new(
349 preview_request_scope(0),
350 acp::ElicitationSchema::new(),
351 ),
352 "Review this request.",
353 ),
354 status: pending_status(),
355 };
356 assert!(should_render_elicitation(&pending));
357
358 let accepted_url = Elicitation {
359 id: ElicitationEntryId("accepted-url".into()),
360 request: acp::CreateElicitationRequest::new(
361 acp::ElicitationUrlMode::new(
362 preview_request_scope(1),
363 acp::ElicitationId::new("accepted-url"),
364 "https://auth.example.com/device",
365 ),
366 "Authorize Zed in your browser.",
367 ),
368 status: ElicitationStatus::Accepted,
369 };
370 assert!(should_render_elicitation(&accepted_url));
371
372 let accepted_form = Elicitation {
373 id: ElicitationEntryId("accepted-form".into()),
374 request: acp::CreateElicitationRequest::new(
375 acp::ElicitationFormMode::new(
376 preview_request_scope(2),
377 acp::ElicitationSchema::new(),
378 ),
379 "Review this request.",
380 ),
381 status: ElicitationStatus::Accepted,
382 };
383 assert!(!should_render_elicitation(&accepted_form));
384 }
385
386 #[test]
387 fn display_url_segments_never_elide_url_characters() {
388 let url = format!(
389 "https://auth.example.com/oauth/authorize?state={}",
390 "a".repeat(MAX_URL_DISPLAY_SEGMENT_CHARS * 2)
391 );
392
393 let display_url_segments = display_url_segments(&url)
394 .into_iter()
395 .map(|segment| segment.to_string())
396 .collect::<Vec<_>>();
397 assert!(display_url_segments.len() > 1);
398 assert!(
399 !display_url_segments
400 .iter()
401 .any(|segment| segment.contains('…'))
402 );
403 assert!(
404 display_url_segments
405 .iter()
406 .all(|segment| segment.chars().count() <= MAX_URL_DISPLAY_SEGMENT_CHARS)
407 );
408 assert_eq!(display_url_segments.concat(), url);
409 }
410
411 #[test]
412 fn display_url_segments_use_larger_url_boundaries() {
413 let url = "https://auth.example.com/oauth/authorize?client_id=zed-desktop&scope=repository";
414
415 let display_url_segments = display_url_segments(url)
416 .into_iter()
417 .map(|segment| segment.to_string())
418 .collect::<Vec<_>>();
419 assert_eq!(display_url_segments.concat(), url);
420 assert_eq!(display_url_segments[0], "https://auth.example.com/");
421 assert!(
422 display_url_segments
423 .iter()
424 .any(|segment| segment.ends_with('?'))
425 );
426 assert!(
427 display_url_segments
428 .iter()
429 .any(|segment| segment.ends_with('&'))
430 );
431 }
432
433 #[test]
434 fn single_select_options_include_titled_descriptions() {
435 let schema = acp::StringPropertySchema::new().one_of(vec![
436 acp::EnumOption::new("production", "Production").description("Use live resources"),
437 ]);
438
439 let options = single_select_options(&schema);
440
441 let [option] = options.as_slice() else {
442 panic!("expected one option, got {}", options.len());
443 };
444 assert_eq!(option.value, "production");
445 assert_eq!(option.label.to_string(), "Production");
446 assert_eq!(
447 option
448 .description
449 .as_ref()
450 .map(|description| description.to_string()),
451 Some("Use live resources".to_string())
452 );
453 }
454
455 #[test]
456 fn multi_select_options_include_titled_descriptions() {
457 let schema = acp::MultiSelectPropertySchema::titled(vec![
458 acp::EnumOption::new("repository", "Repository Access")
459 .description("Read and update repositories"),
460 ]);
461
462 let options = multi_select_options(&schema);
463
464 let [option] = options.as_slice() else {
465 panic!("expected one option, got {}", options.len());
466 };
467 assert_eq!(option.value, "repository");
468 assert_eq!(option.label.to_string(), "Repository Access");
469 assert_eq!(
470 option
471 .description
472 .as_ref()
473 .map(|description| description.to_string()),
474 Some("Read and update repositories".to_string())
475 );
476 }
477
478 #[gpui::test]
479 fn form_state_preserves_string_whitespace(cx: &mut TestAppContext) {
480 crate::conversation_view::tests::init_test(cx);
481
482 cx.add_window(|window, cx| {
483 let schema = acp::ElicitationSchema::new().property(
484 "token",
485 acp::StringPropertySchema::new()
486 .title("Token")
487 .default_value(" secret "),
488 true,
489 );
490 let form_state = ElicitationFormState::new(&schema, window, cx);
491 let content = form_state
492 .collect(&schema, cx)
493 .expect("string with whitespace should be submitted");
494
495 assert_eq!(
496 content.get("token"),
497 Some(&acp::ElicitationContentValue::String(
498 " secret ".to_string()
499 ))
500 );
501
502 Editor::single_line(window, cx)
503 });
504 }
505
506 #[gpui::test]
507 fn form_state_discards_invalid_optional_single_select_default(cx: &mut TestAppContext) {
508 crate::conversation_view::tests::init_test(cx);
509
510 cx.add_window(|window, cx| {
511 let schema = acp::ElicitationSchema::new().property(
512 "environment",
513 acp::StringPropertySchema::new()
514 .title("Environment")
515 .enum_values(vec!["production".to_string(), "staging".to_string()])
516 .default_value("development"),
517 false,
518 );
519 let form_state = ElicitationFormState::new(&schema, window, cx);
520 let content = form_state
521 .collect(&schema, cx)
522 .expect("invalid optional default should be ignored");
523
524 assert_eq!(content.get("environment"), None);
525
526 Editor::single_line(window, cx)
527 });
528 }
529
530 #[gpui::test]
531 fn form_state_replaces_invalid_required_single_select_default(cx: &mut TestAppContext) {
532 crate::conversation_view::tests::init_test(cx);
533
534 cx.add_window(|window, cx| {
535 let schema = acp::ElicitationSchema::new().property(
536 "environment",
537 acp::StringPropertySchema::new()
538 .title("Environment")
539 .enum_values(vec!["production".to_string(), "staging".to_string()])
540 .default_value("development"),
541 true,
542 );
543 let form_state = ElicitationFormState::new(&schema, window, cx);
544 let content = form_state
545 .collect(&schema, cx)
546 .expect("required select should use the first valid choice");
547
548 assert_eq!(
549 content.get("environment"),
550 Some(&acp::ElicitationContentValue::String(
551 "production".to_string()
552 ))
553 );
554
555 Editor::single_line(window, cx)
556 });
557 }
558
559 #[gpui::test]
560 fn form_state_rejects_invalid_single_select_value(cx: &mut TestAppContext) {
561 crate::conversation_view::tests::init_test(cx);
562
563 cx.add_window(|window, cx| {
564 let schema = acp::ElicitationSchema::new().property(
565 "environment",
566 acp::StringPropertySchema::new()
567 .title("Environment")
568 .enum_values(vec!["production".to_string(), "staging".to_string()]),
569 false,
570 );
571 let mut form_state = ElicitationFormState::new(&schema, window, cx);
572 form_state.set_single_select("environment", "development".to_string());
573
574 let errors = form_state
575 .collect(&schema, cx)
576 .expect_err("invalid selected value should be rejected");
577 assert_eq!(
578 errors
579 .get("environment")
580 .expect("environment should have an error")
581 .to_string(),
582 "Environment must be one of the provided options"
583 );
584
585 Editor::single_line(window, cx)
586 });
587 }
588
589 #[gpui::test]
590 fn form_state_reports_all_validation_errors(cx: &mut TestAppContext) {
591 crate::conversation_view::tests::init_test(cx);
592
593 cx.add_window(|window, cx| {
594 let schema = acp::ElicitationSchema::new()
595 .string("account", true)
596 .property(
597 "age",
598 acp::IntegerPropertySchema::new().title("Age").minimum(18),
599 true,
600 )
601 .property(
602 "environment",
603 acp::StringPropertySchema::new()
604 .title("Environment")
605 .enum_values(vec!["production".to_string(), "staging".to_string()]),
606 false,
607 );
608 let mut form_state = ElicitationFormState::new(&schema, window, cx);
609 if let Some(ElicitationFieldState::Text(editor)) = form_state.fields.get("age") {
610 editor.update(cx, |editor, cx| editor.set_text("abc", window, cx));
611 }
612 form_state.set_single_select("environment", "development".to_string());
613
614 let errors = form_state
615 .collect(&schema, cx)
616 .expect_err("all invalid fields should be reported");
617 assert_eq!(
618 errors
619 .get("account")
620 .expect("account should have an error")
621 .to_string(),
622 "account is required"
623 );
624 assert_eq!(
625 errors
626 .get("age")
627 .expect("age should have an error")
628 .to_string(),
629 "Age must be an integer"
630 );
631 assert_eq!(
632 errors
633 .get("environment")
634 .expect("environment should have an error")
635 .to_string(),
636 "Environment must be one of the provided options"
637 );
638
639 Editor::single_line(window, cx)
640 });
641 }
642}
643
644#[derive(RegisterComponent)]
645pub struct ElicitationCardPreview;
646
647impl Component for ElicitationCardPreview {
648 fn scope() -> ComponentScope {
649 ComponentScope::Agent
650 }
651
652 fn description() -> &'static str {
653 "ACP elicitation request cards as rendered in the agent panel."
654 }
655
656 fn preview(window: &mut Window, cx: &mut App) -> AnyElement {
657 v_flex()
658 .gap_6()
659 .children([
660 example_group_with_title(
661 "Form Requests",
662 vec![
663 single_example(
664 "Pending Form",
665 render_form_preview(0, pending_status(), &[], window, cx),
666 )
667 .width(px(640.)),
668 single_example(
669 "Validation Errors",
670 render_form_preview(
671 1,
672 pending_status(),
673 &[
674 ("account_name", "Account name is required"),
675 ("environment", "Choose an environment"),
676 ("scopes", "Choose at least one access scope"),
677 ],
678 window,
679 cx,
680 ),
681 )
682 .width(px(640.)),
683 ],
684 )
685 .vertical()
686 .into_any_element(),
687 example_group_with_title(
688 "URL Requests",
689 vec![
690 single_example(
691 "URL Consent",
692 render_url_preview(3, pending_status(), window, cx),
693 )
694 .width(px(640.)),
695 ],
696 )
697 .vertical()
698 .into_any_element(),
699 example_group_with_title(
700 "Terminal States",
701 vec![
702 single_example(
703 "Declined",
704 render_form_preview(6, ElicitationStatus::Declined, &[], window, cx),
705 )
706 .width(px(640.)),
707 single_example(
708 "Canceled",
709 render_form_preview(7, ElicitationStatus::Canceled, &[], window, cx),
710 )
711 .width(px(640.)),
712 ],
713 )
714 .vertical()
715 .into_any_element(),
716 ])
717 .into_any_element()
718 }
719}
720
721fn render_form_preview(
722 entry_ix: usize,
723 status: ElicitationStatus,
724 field_errors: &[(&'static str, &'static str)],
725 window: &mut Window,
726 cx: &mut App,
727) -> AnyElement {
728 let request = acp::CreateElicitationRequest::new(
729 acp::ElicitationFormMode::new(preview_request_scope(entry_ix), preview_form_schema()),
730 "Choose how Omega should connect to this account.",
731 );
732 let mut form_state = matches!(status, ElicitationStatus::Pending { .. }).then(|| {
733 let acp::ElicitationMode::Form(mode) = &request.mode else {
734 unreachable!();
735 };
736 ElicitationFormState::new(&mode.requested_schema, window, cx)
737 });
738 if let Some(form_state) = &mut form_state {
739 for (field_name, error) in field_errors {
740 form_state.set_field_error(*field_name, *error);
741 }
742 }
743
744 render_preview_card(entry_ix, request, status, form_state.as_ref(), cx)
745}
746
747fn preview_url() -> &'static str {
748 "https://auth.example.com/oauth/authorize?client_id=zed-desktop&redirect_uri=zed%3A%2F%2Fagent%2Facp%2Fcallback&scope=profile%20repository%20terminal&state=9b8b0a873a1e4b57b7f9f7b6d2d3d0f4"
749}
750
751fn render_url_preview(
752 entry_ix: usize,
753 status: ElicitationStatus,
754 _window: &mut Window,
755 cx: &mut App,
756) -> AnyElement {
757 let request = acp::CreateElicitationRequest::new(
758 acp::ElicitationUrlMode::new(
759 preview_request_scope(entry_ix),
760 acp::ElicitationId::new(format!("preview-url-{entry_ix}")),
761 preview_url(),
762 ),
763 "Authorize Omega in your browser to finish signing in.",
764 );
765
766 render_preview_card(entry_ix, request, status, None, cx)
767}
768
769fn render_preview_card(
770 entry_ix: usize,
771 request: acp::CreateElicitationRequest,
772 status: ElicitationStatus,
773 form_state: Option<&ElicitationFormState>,
774 cx: &App,
775) -> AnyElement {
776 let elicitation = Elicitation {
777 id: ElicitationEntryId(format!("preview-elicitation-{entry_ix}").into()),
778 request,
779 status,
780 };
781
782 div()
783 .w_full()
784 .max_w(px(640.))
785 .child(
786 ElicitationCard::new(
787 entry_ix,
788 &elicitation,
789 form_state,
790 ElicitationCardHandlers::noop(),
791 )
792 .render(cx),
793 )
794 .into_any_element()
795}
796
797fn pending_status() -> ElicitationStatus {
798 let (respond_tx, _response_rx) = oneshot::channel();
799 ElicitationStatus::Pending { respond_tx }
800}
801
802fn preview_request_scope(index: usize) -> acp::ElicitationRequestScope {
803 acp::ElicitationRequestScope::new(acp::RequestId::Number(index as i64))
804}
805
806fn preview_form_schema() -> acp::ElicitationSchema {
807 acp::ElicitationSchema::new()
808 .property(
809 "account_name",
810 acp::StringPropertySchema::new()
811 .title("Account Name")
812 .description("Used to label this connection in the agent panel.")
813 .default_value("Work"),
814 true,
815 )
816 .property(
817 "environment",
818 acp::StringPropertySchema::new()
819 .title("Environment")
820 .description("Select the environment this credential should target.")
821 .one_of(vec![
822 acp::EnumOption::new("production", "Production")
823 .description("Use the live account and production resources."),
824 acp::EnumOption::new("staging", "Staging")
825 .description("Validate changes against staging data first."),
826 acp::EnumOption::new("development", "Development"),
827 ])
828 .default_value("staging"),
829 true,
830 )
831 .property(
832 "scopes",
833 acp::MultiSelectPropertySchema::titled(vec![
834 acp::EnumOption::new("profile", "Profile")
835 .description("Read account identity and basic profile details."),
836 acp::EnumOption::new("repository", "Repository Access")
837 .description("Read and update repositories connected to this account."),
838 acp::EnumOption::new("terminal", "Terminal Commands"),
839 ])
840 .title("Access")
841 .description("Choose what the agent can use for this authorization.")
842 .min_items(1)
843 .default_value(vec!["profile".to_string(), "repository".to_string()]),
844 true,
845 )
846 .property(
847 "remember",
848 acp::BooleanPropertySchema::new()
849 .title("Remember Authorization")
850 .description("Store this authorization for future sessions.")
851 .default_value(true),
852 false,
853 )
854}
855
856fn single_select_options(schema: &acp::StringPropertySchema) -> Vec<ElicitationOption> {
857 if let Some(options) = &schema.one_of {
858 return options
859 .iter()
860 .map(|option| ElicitationOption {
861 value: option.value.clone(),
862 label: SharedString::from(option.title.clone()),
863 description: option.description.clone().map(SharedString::from),
864 })
865 .collect();
866 }
867
868 schema
869 .enum_values
870 .as_deref()
871 .unwrap_or_default()
872 .iter()
873 .map(|value| ElicitationOption {
874 value: value.clone(),
875 label: SharedString::from(value.clone()),
876 description: None,
877 })
878 .collect()
879}
880
881fn single_select_default_value(
882 schema: &acp::StringPropertySchema,
883 options: &[ElicitationOption],
884) -> Option<String> {
885 schema
886 .default
887 .as_ref()
888 .filter(|default| {
889 options
890 .iter()
891 .any(|option| option.value.as_str() == default.as_str())
892 })
893 .cloned()
894}
895
896fn multi_select_options(schema: &acp::MultiSelectPropertySchema) -> Vec<ElicitationOption> {
897 match &schema.items {
898 acp::MultiSelectItems::String(items) => items
899 .values
900 .iter()
901 .map(|value| ElicitationOption {
902 value: value.clone(),
903 label: SharedString::from(value.clone()),
904 description: None,
905 })
906 .collect(),
907 acp::MultiSelectItems::Titled(items) => items
908 .options
909 .iter()
910 .map(|option| ElicitationOption {
911 value: option.value.clone(),
912 label: SharedString::from(option.title.clone()),
913 description: option.description.clone().map(SharedString::from),
914 })
915 .collect(),
916 _ => Vec::new(),
917 }
918}
919
920fn validate_number_value(
921 title: SharedString,
922 schema: &acp::NumberPropertySchema,
923 value: &str,
924) -> Result<f64, SharedString> {
925 let parsed = value
926 .parse::<f64>()
927 .map_err(|_| SharedString::from(format!("{title} must be a number")))?;
928 if !parsed.is_finite() {
929 return Err(format!("{title} must be a finite number").into());
930 }
931 if let Some(minimum) = schema.minimum
932 && parsed < minimum
933 {
934 return Err(format!("{title} must be at least {minimum}").into());
935 }
936 if let Some(maximum) = schema.maximum
937 && parsed > maximum
938 {
939 return Err(format!("{title} must be at most {maximum}").into());
940 }
941
942 Ok(parsed)
943}
944
945fn validate_integer_value(
946 title: SharedString,
947 schema: &acp::IntegerPropertySchema,
948 value: &str,
949) -> Result<i64, SharedString> {
950 let parsed = value
951 .parse::<i64>()
952 .map_err(|_| SharedString::from(format!("{title} must be an integer")))?;
953 if let Some(minimum) = schema.minimum
954 && parsed < minimum
955 {
956 return Err(format!("{title} must be at least {minimum}").into());
957 }
958 if let Some(maximum) = schema.maximum
959 && parsed > maximum
960 {
961 return Err(format!("{title} must be at most {maximum}").into());
962 }
963
964 Ok(parsed)
965}
966
967fn validate_string_value(
968 title: SharedString,
969 schema: &acp::StringPropertySchema,
970 value: &str,
971) -> Result<(), SharedString> {
972 let length = value.chars().count();
973 if schema
974 .min_length
975 .is_some_and(|min_length| length < min_length as usize)
976 {
977 return Err(format!("{title} is too short").into());
978 }
979 if schema
980 .max_length
981 .is_some_and(|max_length| length > max_length as usize)
982 {
983 return Err(format!("{title} is too long").into());
984 }
985
986 validate_string_pattern_and_format(title, schema, value)
987}
988
989fn validate_single_select_value(
990 title: SharedString,
991 schema: &acp::StringPropertySchema,
992 value: &str,
993) -> Result<(), SharedString> {
994 let options = single_select_options(schema);
995 if options.iter().any(|option| option.value.as_str() == value) {
996 Ok(())
997 } else {
998 Err(format!("{title} must be one of the provided options").into())
999 }
1000}
1001
1002fn validate_string_pattern_and_format(
1003 title: SharedString,
1004 schema: &acp::StringPropertySchema,
1005 value: &str,
1006) -> Result<(), SharedString> {
1007 if schema.pattern.is_none() && schema.format.and_then(string_format_json_name).is_none() {
1008 return Ok(());
1009 }
1010
1011 let mut validation_schema = serde_json::Map::new();
1012 validation_schema.insert(
1013 "type".to_string(),
1014 serde_json::Value::String("string".into()),
1015 );
1016 if let Some(pattern) = &schema.pattern {
1017 validation_schema.insert(
1018 "pattern".to_string(),
1019 serde_json::Value::String(pattern.clone()),
1020 );
1021 }
1022 if let Some(format) = schema.format.and_then(string_format_json_name) {
1023 validation_schema.insert(
1024 "format".to_string(),
1025 serde_json::Value::String(format.into()),
1026 );
1027 }
1028
1029 let validation_schema = serde_json::Value::Object(validation_schema);
1030 let validator = jsonschema::options()
1031 .should_validate_formats(true)
1032 .build(&validation_schema)
1033 .map_err(|_| {
1034 if schema.pattern.is_some() {
1035 format!("{title} has an invalid validation pattern")
1036 } else {
1037 format!("{title} has an invalid validation format")
1038 }
1039 })?;
1040 if validator.is_valid(&serde_json::Value::String(value.to_string())) {
1041 return Ok(());
1042 }
1043
1044 match (
1045 schema.pattern.is_some(),
1046 schema.format.and_then(string_format_label),
1047 ) {
1048 (true, Some(_)) => Err(format!("{title} does not match the requested constraints").into()),
1049 (true, None) => Err(format!("{title} does not match the requested pattern").into()),
1050 (false, Some(format)) => Err(format!("{title} must be {format}").into()),
1051 (false, None) => Ok(()),
1052 }
1053}
1054
1055fn string_format_json_name(format: acp::StringFormat) -> Option<&'static str> {
1056 match format {
1057 acp::StringFormat::Email => Some("email"),
1058 acp::StringFormat::Uri => Some("uri"),
1059 acp::StringFormat::Date => Some("date"),
1060 acp::StringFormat::DateTime => Some("date-time"),
1061 _ => None,
1062 }
1063}
1064
1065fn string_format_label(format: acp::StringFormat) -> Option<&'static str> {
1066 match format {
1067 acp::StringFormat::Email => Some("an email address"),
1068 acp::StringFormat::Uri => Some("a URI"),
1069 acp::StringFormat::Date => Some("a date"),
1070 acp::StringFormat::DateTime => Some("a date and time"),
1071 _ => None,
1072 }
1073}
1074
1075fn property_title(name: &str, property: &acp::ElicitationPropertySchema) -> SharedString {
1076 let title = match property {
1077 acp::ElicitationPropertySchema::String(schema) => schema.title.as_deref(),
1078 acp::ElicitationPropertySchema::Number(schema) => schema.title.as_deref(),
1079 acp::ElicitationPropertySchema::Integer(schema) => schema.title.as_deref(),
1080 acp::ElicitationPropertySchema::Boolean(schema) => schema.title.as_deref(),
1081 acp::ElicitationPropertySchema::Array(schema) => schema.title.as_deref(),
1082 _ => None,
1083 };
1084 SharedString::from(title.unwrap_or(name).to_string())
1085}
1086
1087fn property_description(property: &acp::ElicitationPropertySchema) -> Option<SharedString> {
1088 match property {
1089 acp::ElicitationPropertySchema::String(schema) => schema.description.clone(),
1090 acp::ElicitationPropertySchema::Number(schema) => schema.description.clone(),
1091 acp::ElicitationPropertySchema::Integer(schema) => schema.description.clone(),
1092 acp::ElicitationPropertySchema::Boolean(schema) => schema.description.clone(),
1093 acp::ElicitationPropertySchema::Array(schema) => schema.description.clone(),
1094 _ => None,
1095 }
1096 .map(SharedString::from)
1097}
1098
1099type RespondHandler = Rc<dyn Fn(ElicitationEntryId, &mut Window, &mut App)>;
1100type OpenUrlHandler = Rc<dyn Fn(ElicitationEntryId, String, &mut Window, &mut App)>;
1101type BooleanHandler = Rc<dyn Fn(ElicitationEntryId, String, bool, &mut App)>;
1102type SelectHandler = Rc<dyn Fn(ElicitationEntryId, String, String, &mut App)>;
1103type MultiSelectHandler = Rc<dyn Fn(ElicitationEntryId, String, String, bool, &mut App)>;
1104
1105#[derive(Clone)]
1106pub(crate) struct ElicitationCardHandlers {
1107 on_submit: RespondHandler,
1108 on_decline: RespondHandler,
1109 on_cancel: RespondHandler,
1110 on_open_url: OpenUrlHandler,
1111 on_boolean_change: BooleanHandler,
1112 on_single_select_change: SelectHandler,
1113 on_multi_select_change: MultiSelectHandler,
1114}
1115
1116impl ElicitationCardHandlers {
1117 pub(crate) fn new(
1118 on_submit: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static,
1119 on_decline: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static,
1120 on_cancel: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static,
1121 on_open_url: impl Fn(ElicitationEntryId, String, &mut Window, &mut App) + 'static,
1122 on_boolean_change: impl Fn(ElicitationEntryId, String, bool, &mut App) + 'static,
1123 on_single_select_change: impl Fn(ElicitationEntryId, String, String, &mut App) + 'static,
1124 on_multi_select_change: impl Fn(ElicitationEntryId, String, String, bool, &mut App) + 'static,
1125 ) -> Self {
1126 Self {
1127 on_submit: Rc::new(on_submit),
1128 on_decline: Rc::new(on_decline),
1129 on_cancel: Rc::new(on_cancel),
1130 on_open_url: Rc::new(on_open_url),
1131 on_boolean_change: Rc::new(on_boolean_change),
1132 on_single_select_change: Rc::new(on_single_select_change),
1133 on_multi_select_change: Rc::new(on_multi_select_change),
1134 }
1135 }
1136
1137 pub(crate) fn noop() -> Self {
1138 Self::new(
1139 |_, _, _| {},
1140 |_, _, _| {},
1141 |_, _, _| {},
1142 |_, _, _, _| {},
1143 |_, _, _, _| {},
1144 |_, _, _, _| {},
1145 |_, _, _, _, _| {},
1146 )
1147 }
1148}
1149
1150pub(crate) fn should_render_elicitation(elicitation: &Elicitation) -> bool {
1151 matches!(
1152 (&elicitation.status, &elicitation.request.mode),
1153 (ElicitationStatus::Pending { .. }, _)
1154 | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_))
1155 )
1156}
1157
1158const MIN_URL_DISPLAY_SEGMENT_CHARS: usize = 16;
1159const MAX_URL_DISPLAY_SEGMENT_CHARS: usize = 64;
1160
1161fn display_url_segments(url: &str) -> Vec<SharedString> {
1162 let mut segments = Vec::new();
1163 let mut segment = String::new();
1164 let mut segment_chars = 0;
1165 let mut characters = url.chars().peekable();
1166
1167 while let Some(character) = characters.next() {
1168 segment.push(character);
1169 segment_chars += 1;
1170
1171 let should_split_at_boundary = segment_chars >= MIN_URL_DISPLAY_SEGMENT_CHARS
1172 && is_url_display_segment_boundary(character);
1173 let should_split_at_length = segment_chars >= MAX_URL_DISPLAY_SEGMENT_CHARS;
1174
1175 if characters.peek().is_some() && (should_split_at_boundary || should_split_at_length) {
1176 segments.push(std::mem::take(&mut segment).into());
1177 segment_chars = 0;
1178 }
1179 }
1180
1181 if !segment.is_empty() {
1182 segments.push(segment.into());
1183 }
1184
1185 segments
1186}
1187
1188fn is_url_display_segment_boundary(character: char) -> bool {
1189 matches!(character, '/' | '?' | '&' | '#')
1190}
1191
1192pub(crate) struct ElicitationCard<'a> {
1193 entry_ix: usize,
1194 elicitation: &'a Elicitation,
1195 form_state: Option<&'a ElicitationFormState>,
1196 handlers: ElicitationCardHandlers,
1197}
1198
1199impl<'a> ElicitationCard<'a> {
1200 pub(crate) fn new(
1201 entry_ix: usize,
1202 elicitation: &'a Elicitation,
1203 form_state: Option<&'a ElicitationFormState>,
1204 handlers: ElicitationCardHandlers,
1205 ) -> Self {
1206 Self {
1207 entry_ix,
1208 elicitation,
1209 form_state,
1210 handlers,
1211 }
1212 }
1213
1214 pub(crate) fn render(self, cx: &App) -> Div {
1215 let border_color = cx.theme().colors().border.opacity(0.8);
1216 let header_background = cx
1217 .theme()
1218 .colors()
1219 .element_background
1220 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
1221 let tool_name_font_size = rems_from_px(13.);
1222 let is_pending = matches!(&self.elicitation.status, ElicitationStatus::Pending { .. });
1223 let is_accepted_url = matches!(
1224 (&self.elicitation.status, &self.elicitation.request.mode),
1225 (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_))
1226 );
1227 let (status_label, status_icon, status_color) = match &self.elicitation.status {
1228 ElicitationStatus::Pending { .. } => ("Waiting for input", IconName::Info, Color::Info),
1229 ElicitationStatus::Accepted if is_accepted_url => {
1230 ("Waiting for completion", IconName::Info, Color::Info)
1231 }
1232 ElicitationStatus::Accepted => ("Submitted", IconName::Check, Color::Success),
1233 ElicitationStatus::Declined => ("Declined", IconName::Close, Color::Muted),
1234 ElicitationStatus::Canceled => ("Canceled", IconName::Circle, Color::Muted),
1235 ElicitationStatus::Completed => ("Completed", IconName::Check, Color::Success),
1236 };
1237
1238 let body = v_flex()
1239 .gap_2()
1240 .p_3()
1241 .child(Label::new(self.elicitation.request.message.clone()).size(LabelSize::Small));
1242 let body = match &self.elicitation.request.mode {
1243 acp::ElicitationMode::Form(mode) if is_pending => {
1244 body.child(self.render_form(mode, cx))
1245 }
1246 acp::ElicitationMode::Url(mode) if is_pending || is_accepted_url => {
1247 body.child(self.render_url_elicitation(mode))
1248 }
1249 _ => body,
1250 };
1251
1252 v_flex()
1253 .mx_5()
1254 .my_1p5()
1255 .rounded_md()
1256 .border_1()
1257 .border_color(border_color)
1258 .overflow_hidden()
1259 .child(
1260 h_flex()
1261 .h_8()
1262 .p_1()
1263 .w_full()
1264 .justify_between()
1265 .bg(header_background)
1266 .child(
1267 h_flex()
1268 .min_w_0()
1269 .gap_1p5()
1270 .px_1()
1271 .child(
1272 Icon::new(status_icon)
1273 .size(IconSize::Small)
1274 .color(status_color),
1275 )
1276 .child(
1277 Label::new("Input Requested")
1278 .size(LabelSize::Custom(tool_name_font_size))
1279 .truncate(),
1280 ),
1281 )
1282 .child(
1283 Label::new(status_label)
1284 .size(LabelSize::Small)
1285 .color(Color::Muted),
1286 ),
1287 )
1288 .child(body)
1289 .when(is_pending, |this| this.child(self.render_actions(cx)))
1290 }
1291
1292 fn render_form(&self, mode: &acp::ElicitationFormMode, cx: &App) -> AnyElement {
1293 let Some(state) = self.form_state else {
1294 return Empty.into_any_element();
1295 };
1296
1297 v_flex()
1298 .gap_2()
1299 .children(mode.requested_schema.properties.iter().filter_map(
1300 |(field_name, property)| {
1301 let field = state.fields.get(field_name)?;
1302 Some(self.render_field(
1303 field_name,
1304 property,
1305 field,
1306 state.field_errors.get(field_name),
1307 cx,
1308 ))
1309 },
1310 ))
1311 .into_any_element()
1312 }
1313
1314 fn render_field(
1315 &self,
1316 field_name: &str,
1317 property: &acp::ElicitationPropertySchema,
1318 field: &ElicitationFieldState,
1319 error: Option<&SharedString>,
1320 cx: &App,
1321 ) -> AnyElement {
1322 let label = property_title(field_name, property);
1323 let description = property_description(property);
1324 let border_color = cx.theme().colors().border.opacity(0.8);
1325 let field_border_color = if error.is_some() {
1326 Color::Error.color(cx)
1327 } else {
1328 border_color
1329 };
1330 let editor_background = cx.theme().colors().editor_background;
1331 let label_color = if error.is_some() {
1332 Color::Error
1333 } else {
1334 Color::Default
1335 };
1336
1337 if let ElicitationFieldState::Boolean(value) = field {
1338 let checkbox_state = if *value {
1339 ToggleState::Selected
1340 } else {
1341 ToggleState::Unselected
1342 };
1343 let next_value = !*value;
1344 let on_boolean_change = self.handlers.on_boolean_change.clone();
1345 let elicitation_id = self.elicitation.id.clone();
1346 let field_name = field_name.to_string();
1347 let row_id = format!("elicitation-bool-row-{}-{field_name}", self.entry_ix);
1348 let checkbox_id = format!("elicitation-bool-{}-{field_name}", self.entry_ix);
1349
1350 return v_flex()
1351 .gap_1()
1352 .child(
1353 h_flex()
1354 .id(row_id)
1355 .w_full()
1356 .items_start()
1357 .gap_1()
1358 .cursor_pointer()
1359 .on_click(move |_, _window, cx| {
1360 on_boolean_change(
1361 elicitation_id.clone(),
1362 field_name.clone(),
1363 next_value,
1364 cx,
1365 );
1366 })
1367 .child(div().child(Checkbox::new(checkbox_id, checkbox_state)))
1368 .child(
1369 v_flex()
1370 .gap_0p5()
1371 .child(Label::new(label).size(LabelSize::Small).color(label_color))
1372 .when_some(description, |this, description| {
1373 this.child(
1374 Label::new(description)
1375 .size(LabelSize::Small)
1376 .color(Color::Muted),
1377 )
1378 }),
1379 ),
1380 )
1381 .when_some(error.cloned(), |this, error| {
1382 this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
1383 })
1384 .into_any_element();
1385 }
1386
1387 let label = if error.is_some() {
1388 Label::new(label).size(LabelSize::Small).color(Color::Error)
1389 } else {
1390 Label::new(label).size(LabelSize::Small)
1391 };
1392
1393 v_flex()
1394 .gap_1()
1395 .child(label)
1396 .when_some(description, |this, description| {
1397 this.child(
1398 Label::new(description)
1399 .size(LabelSize::Small)
1400 .color(Color::Muted),
1401 )
1402 })
1403 .child(match field {
1404 ElicitationFieldState::Text(editor) => div()
1405 .rounded_sm()
1406 .border_1()
1407 .border_color(field_border_color)
1408 .bg(editor_background)
1409 .px_1()
1410 .py_0p5()
1411 .text_xs()
1412 .child(editor.clone().into_any_element())
1413 .into_any_element(),
1414 ElicitationFieldState::Boolean(_) => Empty.into_any_element(),
1415 ElicitationFieldState::SingleSelect { value } => {
1416 let options = match property {
1417 acp::ElicitationPropertySchema::String(schema) => {
1418 single_select_options(schema)
1419 }
1420 _ => Vec::new(),
1421 };
1422 self.render_single_select(
1423 field_name,
1424 value.as_ref(),
1425 options,
1426 error.is_some(),
1427 cx,
1428 )
1429 }
1430 ElicitationFieldState::MultiSelect(selected) => {
1431 let options = match property {
1432 acp::ElicitationPropertySchema::Array(schema) => {
1433 multi_select_options(schema)
1434 }
1435 _ => Vec::new(),
1436 };
1437 v_flex()
1438 .gap_1()
1439 .children(options.into_iter().map(|option| {
1440 let is_selected = selected.contains(&option.value);
1441 let checkbox_state = if is_selected {
1442 ToggleState::Selected
1443 } else {
1444 ToggleState::Unselected
1445 };
1446 let row_background = Self::option_row_background(is_selected, cx);
1447 let hover_background =
1448 Self::option_row_hover_background(is_selected, cx);
1449 let on_multi_select_change =
1450 self.handlers.on_multi_select_change.clone();
1451 let elicitation_id = self.elicitation.id.clone();
1452 let field_name = field_name.to_string();
1453 let value = option.value.clone();
1454 let checkbox_id = format!(
1455 "elicitation-multi-{}-{field_name}-{}",
1456 self.entry_ix, option.value
1457 );
1458 h_flex()
1459 .id(SharedString::from(format!(
1460 "elicitation-multi-option-{}-{field_name}-{}",
1461 self.entry_ix, option.value
1462 )))
1463 .w_full()
1464 .min_h(rems_from_px(28.))
1465 .items_start()
1466 .gap_1p5()
1467 .rounded_sm()
1468 .border_1()
1469 .border_color(field_border_color.opacity(0.5))
1470 .bg(row_background)
1471 .px_2()
1472 .py_1()
1473 .hover(move |this| this.bg(hover_background).cursor_pointer())
1474 .on_click(move |_, _window, cx| {
1475 on_multi_select_change(
1476 elicitation_id.clone(),
1477 field_name.clone(),
1478 value.clone(),
1479 !is_selected,
1480 cx,
1481 );
1482 })
1483 .child(div().child(Checkbox::new(checkbox_id, checkbox_state)))
1484 .child(Self::render_option_content(option))
1485 }))
1486 .into_any_element()
1487 }
1488 })
1489 .when_some(error.cloned(), |this, error| {
1490 this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
1491 })
1492 .into_any_element()
1493 }
1494
1495 fn render_single_select(
1496 &self,
1497 field_name: &str,
1498 selected_value: Option<&String>,
1499 options: Vec<ElicitationOption>,
1500 has_error: bool,
1501 cx: &App,
1502 ) -> AnyElement {
1503 let entry_ix = self.entry_ix;
1504 let border_color = if has_error {
1505 Color::Error.color(cx)
1506 } else {
1507 cx.theme().colors().border.opacity(0.8)
1508 };
1509 let elicitation_id = self.elicitation.id.clone();
1510 let field_name = field_name.to_string();
1511 let on_single_select_change = self.handlers.on_single_select_change.clone();
1512
1513 v_flex()
1514 .gap_1()
1515 .children(options.into_iter().map(move |option| {
1516 let option_value = option.value.clone();
1517 let option_id =
1518 format!("elicitation-select-option-{entry_ix}-{field_name}-{option_value}");
1519 let is_selected =
1520 selected_value.is_some_and(|selected_value| selected_value == &option.value);
1521 let row_background = Self::option_row_background(is_selected, cx);
1522 let hover_background = Self::option_row_hover_background(is_selected, cx);
1523 let control_background = Self::option_control_background(cx);
1524 let elicitation_id = elicitation_id.clone();
1525 let field_name = field_name.clone();
1526 let on_single_select_change = on_single_select_change.clone();
1527
1528 h_flex()
1529 .id(option_id)
1530 .w_full()
1531 .min_h(rems_from_px(28.))
1532 .items_start()
1533 .gap_1p5()
1534 .rounded_sm()
1535 .border_1()
1536 .border_color(border_color.opacity(0.5))
1537 .bg(row_background)
1538 .px_2()
1539 .py_1()
1540 .hover(move |this| this.bg(hover_background).cursor_pointer())
1541 .on_click(move |_, _window, cx| {
1542 on_single_select_change(
1543 elicitation_id.clone(),
1544 field_name.clone(),
1545 option_value.clone(),
1546 cx,
1547 );
1548 })
1549 .child(
1550 div()
1551 .size(Checkbox::container_size())
1552 .flex_none()
1553 .flex()
1554 .items_center()
1555 .justify_center()
1556 .child(Self::render_radio_indicator(
1557 is_selected,
1558 border_color,
1559 control_background,
1560 )),
1561 )
1562 .child(Self::render_option_content(option))
1563 }))
1564 .into_any_element()
1565 }
1566
1567 fn render_option_content(option: ElicitationOption) -> Div {
1568 v_flex()
1569 .min_w_0()
1570 .flex_1()
1571 .gap_0p5()
1572 .child(Label::new(option.label).size(LabelSize::Small).truncate())
1573 .when_some(option.description, |this, description| {
1574 this.child(
1575 Label::new(description)
1576 .size(LabelSize::Small)
1577 .color(Color::Muted),
1578 )
1579 })
1580 }
1581
1582 fn option_row_background(is_selected: bool, cx: &App) -> Hsla {
1583 let editor_background = cx.theme().colors().editor_background;
1584 if is_selected {
1585 editor_background.blend(Color::Accent.color(cx).opacity(0.08))
1586 } else {
1587 editor_background
1588 }
1589 }
1590
1591 fn option_row_hover_background(is_selected: bool, cx: &App) -> Hsla {
1592 let editor_background = cx.theme().colors().editor_background;
1593 if is_selected {
1594 editor_background.blend(Color::Accent.color(cx).opacity(0.1))
1595 } else {
1596 cx.theme()
1597 .colors()
1598 .element_background
1599 .blend(cx.theme().colors().editor_foreground.opacity(0.025))
1600 }
1601 }
1602
1603 fn option_control_background(cx: &App) -> Hsla {
1604 cx.theme().colors().editor_background
1605 }
1606
1607 fn render_radio_indicator(is_selected: bool, border_color: Hsla, background: Hsla) -> Div {
1608 div()
1609 .size_3()
1610 .flex()
1611 .items_center()
1612 .justify_center()
1613 .rounded_full()
1614 .border_1()
1615 .border_color(border_color)
1616 .bg(background)
1617 .when(is_selected, |this| {
1618 this.child(Indicator::dot().color(Color::Accent))
1619 })
1620 }
1621
1622 fn render_url_elicitation(&self, mode: &acp::ElicitationUrlMode) -> AnyElement {
1623 v_flex()
1624 .gap_2()
1625 .child(Self::render_url_summary(&mode.url))
1626 .into_any_element()
1627 }
1628
1629 fn render_url_summary(url: &str) -> AnyElement {
1630 h_flex()
1631 .gap_1()
1632 .w_full()
1633 .min_w_0()
1634 .items_start()
1635 .child(
1636 div().h(rems_from_px(16.)).flex().items_center().child(
1637 Icon::new(IconName::Link)
1638 .size(IconSize::XSmall)
1639 .color(Color::Muted),
1640 ),
1641 )
1642 .child(h_flex().min_w_0().flex_1().flex_wrap().children(
1643 display_url_segments(url).into_iter().map(|segment| {
1644 Label::new(segment)
1645 .size(LabelSize::Small)
1646 .color(Color::Muted)
1647 }),
1648 ))
1649 .into_any_element()
1650 }
1651
1652 fn render_actions(&self, cx: &App) -> AnyElement {
1653 let open_url = match &self.elicitation.request.mode {
1654 acp::ElicitationMode::Url(mode) => Some(mode.url.clone()),
1655 _ => None,
1656 };
1657 let (accept_label, accept_icon, accept_icon_color) = if open_url.is_some() {
1658 ("Open", IconName::ArrowUpRight, Color::Muted)
1659 } else {
1660 ("Submit", IconName::Check, Color::Success)
1661 };
1662 let border_color = cx.theme().colors().border.opacity(0.8);
1663 let on_submit = self.handlers.on_submit.clone();
1664 let on_open_url = self.handlers.on_open_url.clone();
1665 let on_decline = self.handlers.on_decline.clone();
1666 let on_cancel = self.handlers.on_cancel.clone();
1667 let submit_id = self.elicitation.id.clone();
1668 let decline_id = self.elicitation.id.clone();
1669 let cancel_id = self.elicitation.id.clone();
1670
1671 h_flex()
1672 .w_full()
1673 .p_1()
1674 .gap_1()
1675 .justify_end()
1676 .border_t_1()
1677 .border_color(border_color)
1678 .child(
1679 Button::new(("elicitation-accept", self.entry_ix), accept_label)
1680 .start_icon(
1681 Icon::new(accept_icon)
1682 .size(IconSize::XSmall)
1683 .color(accept_icon_color),
1684 )
1685 .label_size(LabelSize::Small)
1686 .on_click(move |_, window, cx| {
1687 if let Some(url) = &open_url {
1688 on_open_url(submit_id.clone(), url.clone(), window, cx);
1689 } else {
1690 on_submit(submit_id.clone(), window, cx);
1691 }
1692 }),
1693 )
1694 .child(
1695 Button::new(("elicitation-decline", self.entry_ix), "Decline")
1696 .start_icon(
1697 Icon::new(IconName::Close)
1698 .size(IconSize::XSmall)
1699 .color(Color::Error),
1700 )
1701 .label_size(LabelSize::Small)
1702 .on_click(move |_, window, cx| {
1703 on_decline(decline_id.clone(), window, cx);
1704 }),
1705 )
1706 .child(
1707 Button::new(("elicitation-cancel", self.entry_ix), "Cancel")
1708 .label_size(LabelSize::Small)
1709 .on_click(move |_, window, cx| {
1710 on_cancel(cancel_id.clone(), window, cx);
1711 }),
1712 )
1713 .into_any_element()
1714 }
1715}
1716