Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:06:22.369Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

elicitation.rs

1716 lines · 64.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.