1use std::{cmp::Reverse, sync::Arc};
2
3use collections::IndexMap;
4use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
5use gpui::{
6 Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
7};
8use language_model::{
9 AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
10 LanguageModelRegistry,
11};
12use ordered_float::OrderedFloat;
13use picker::{Picker, PickerDelegate};
14use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
15use zed_actions::agent::OpenSettings;
16
17type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
18type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
19
20pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
21
22pub fn language_model_selector(
23 get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
24 on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
25 popover_styles: bool,
26 focus_handle: FocusHandle,
27 window: &mut Window,
28 cx: &mut Context<LanguageModelSelector>,
29) -> LanguageModelSelector {
30 let delegate = LanguageModelPickerDelegate::new(
31 get_active_model,
32 on_model_changed,
33 popover_styles,
34 focus_handle,
35 window,
36 cx,
37 );
38
39 if popover_styles {
40 Picker::list(delegate, window, cx)
41 .show_scrollbar(true)
42 .width(rems(20.))
43 .max_height(Some(rems(20.).into()))
44 } else {
45 Picker::list(delegate, window, cx).show_scrollbar(true)
46 }
47}
48
49fn all_models(cx: &App) -> GroupedModels {
50 let providers = LanguageModelRegistry::global(cx).read(cx).providers();
51
52 let recommended = providers
53 .iter()
54 .flat_map(|provider| {
55 provider
56 .recommended_models(cx)
57 .into_iter()
58 .map(|model| ModelInfo {
59 model,
60 icon: provider.icon(),
61 })
62 })
63 .collect();
64
65 let all = providers
66 .iter()
67 .flat_map(|provider| {
68 provider
69 .provided_models(cx)
70 .into_iter()
71 .map(|model| ModelInfo {
72 model,
73 icon: provider.icon(),
74 })
75 })
76 .collect();
77
78 GroupedModels::new(all, recommended)
79}
80
81#[derive(Clone)]
82struct ModelInfo {
83 model: Arc<dyn LanguageModel>,
84 icon: IconName,
85}
86
87pub struct LanguageModelPickerDelegate {
88 on_model_changed: OnModelChanged,
89 get_active_model: GetActiveModel,
90 all_models: Arc<GroupedModels>,
91 filtered_entries: Vec<LanguageModelPickerEntry>,
92 selected_index: usize,
93 _authenticate_all_providers_task: Task<()>,
94 _subscriptions: Vec<Subscription>,
95 popover_styles: bool,
96 focus_handle: FocusHandle,
97}
98
99impl LanguageModelPickerDelegate {
100 fn new(
101 get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
102 on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
103 popover_styles: bool,
104 focus_handle: FocusHandle,
105 window: &mut Window,
106 cx: &mut Context<Picker<Self>>,
107 ) -> Self {
108 let on_model_changed = Arc::new(on_model_changed);
109 let models = all_models(cx);
110 let entries = models.entries();
111
112 Self {
113 on_model_changed,
114 all_models: Arc::new(models),
115 selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
116 filtered_entries: entries,
117 get_active_model: Arc::new(get_active_model),
118 _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
119 _subscriptions: vec![cx.subscribe_in(
120 &LanguageModelRegistry::global(cx),
121 window,
122 |picker, _, event, window, cx| {
123 match event {
124 language_model::Event::ProviderStateChanged(_)
125 | language_model::Event::AddedProvider(_)
126 | language_model::Event::RemovedProvider(_) => {
127 let query = picker.query(cx);
128 picker.delegate.all_models = Arc::new(all_models(cx));
129 // Update matches will automatically drop the previous task
130 // if we get a provider event again
131 picker.update_matches(query, window, cx)
132 }
133 _ => {}
134 }
135 },
136 )],
137 popover_styles,
138 focus_handle,
139 }
140 }
141
142 fn get_active_model_index(
143 entries: &[LanguageModelPickerEntry],
144 active_model: Option<ConfiguredModel>,
145 ) -> usize {
146 entries
147 .iter()
148 .position(|entry| {
149 if let LanguageModelPickerEntry::Model(model) = entry {
150 active_model
151 .as_ref()
152 .map(|active_model| {
153 active_model.model.id() == model.model.id()
154 && active_model.provider.id() == model.model.provider_id()
155 })
156 .unwrap_or_default()
157 } else {
158 false
159 }
160 })
161 .unwrap_or(0)
162 }
163
164 /// Authenticates all providers in the [`LanguageModelRegistry`].
165 ///
166 /// We do this so that we can populate the language selector with all of the
167 /// models from the configured providers.
168 fn authenticate_all_providers(cx: &mut App) -> Task<()> {
169 let authenticate_all_providers = LanguageModelRegistry::global(cx)
170 .read(cx)
171 .providers()
172 .iter()
173 .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
174 .collect::<Vec<_>>();
175
176 cx.spawn(async move |_cx| {
177 for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
178 if let Err(err) = authenticate_task.await {
179 if matches!(err, AuthenticateError::CredentialsNotFound) {
180 // Since we're authenticating these providers in the
181 // background for the purposes of populating the
182 // language selector, we don't care about providers
183 // where the credentials are not found.
184 } else {
185 // Some providers have noisy failure states that we
186 // don't want to spam the logs with every time the
187 // language model selector is initialized.
188 //
189 // Ideally these should have more clear failure modes
190 // that we know are safe to ignore here, like what we do
191 // with `CredentialsNotFound` above.
192 match provider_id.0.as_ref() {
193 "lmstudio" | "ollama" => {
194 // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
195 //
196 // These fail noisily, so we don't log them.
197 }
198 "copilot_chat" => {
199 // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
200 }
201 _ => {
202 log::error!(
203 "Failed to authenticate provider: {}: {err:#}",
204 provider_name.0
205 );
206 }
207 }
208 }
209 }
210 }
211 })
212 }
213
214 pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
215 (self.get_active_model)(cx)
216 }
217}
218
219struct GroupedModels {
220 recommended: Vec<ModelInfo>,
221 all: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
222}
223
224impl GroupedModels {
225 pub fn new(all: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
226 let mut all_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
227 for model in all {
228 let provider = model.model.provider_id();
229 if let Some(models) = all_by_provider.get_mut(&provider) {
230 models.push(model);
231 } else {
232 all_by_provider.insert(provider, vec![model]);
233 }
234 }
235
236 Self {
237 recommended,
238 all: all_by_provider,
239 }
240 }
241
242 fn entries(&self) -> Vec<LanguageModelPickerEntry> {
243 let mut entries = Vec::new();
244
245 if !self.recommended.is_empty() {
246 entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
247 entries.extend(
248 self.recommended
249 .iter()
250 .map(|info| LanguageModelPickerEntry::Model(info.clone())),
251 );
252 }
253
254 for models in self.all.values() {
255 if models.is_empty() {
256 continue;
257 }
258 entries.push(LanguageModelPickerEntry::Separator(
259 models[0].model.provider_name().0,
260 ));
261 entries.extend(
262 models
263 .iter()
264 .map(|info| LanguageModelPickerEntry::Model(info.clone())),
265 );
266 }
267 entries
268 }
269}
270
271enum LanguageModelPickerEntry {
272 Model(ModelInfo),
273 Separator(SharedString),
274}
275
276struct ModelMatcher {
277 models: Vec<ModelInfo>,
278 bg_executor: BackgroundExecutor,
279 candidates: Vec<StringMatchCandidate>,
280}
281
282impl ModelMatcher {
283 fn new(models: Vec<ModelInfo>, bg_executor: BackgroundExecutor) -> ModelMatcher {
284 let candidates = Self::make_match_candidates(&models);
285 Self {
286 models,
287 bg_executor,
288 candidates,
289 }
290 }
291
292 pub fn fuzzy_search(&self, query: &str) -> Vec<ModelInfo> {
293 let mut matches = self.bg_executor.block(match_strings(
294 &self.candidates,
295 query,
296 false,
297 true,
298 100,
299 &Default::default(),
300 self.bg_executor.clone(),
301 ));
302
303 let sorting_key = |mat: &StringMatch| {
304 let candidate = &self.candidates[mat.candidate_id];
305 (Reverse(OrderedFloat(mat.score)), candidate.id)
306 };
307 matches.sort_unstable_by_key(sorting_key);
308
309 let matched_models: Vec<_> = matches
310 .into_iter()
311 .map(|mat| self.models[mat.candidate_id].clone())
312 .collect();
313
314 matched_models
315 }
316
317 pub fn exact_search(&self, query: &str) -> Vec<ModelInfo> {
318 self.models
319 .iter()
320 .filter(|m| {
321 m.model
322 .name()
323 .0
324 .to_lowercase()
325 .contains(&query.to_lowercase())
326 })
327 .cloned()
328 .collect::<Vec<_>>()
329 }
330
331 fn make_match_candidates(model_infos: &Vec<ModelInfo>) -> Vec<StringMatchCandidate> {
332 model_infos
333 .iter()
334 .enumerate()
335 .map(|(index, model)| {
336 StringMatchCandidate::new(
337 index,
338 &format!(
339 "{}/{}",
340 &model.model.provider_name().0,
341 &model.model.name().0
342 ),
343 )
344 })
345 .collect::<Vec<_>>()
346 }
347}
348
349impl PickerDelegate for LanguageModelPickerDelegate {
350 type ListItem = AnyElement;
351
352 fn match_count(&self) -> usize {
353 self.filtered_entries.len()
354 }
355
356 fn selected_index(&self) -> usize {
357 self.selected_index
358 }
359
360 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
361 self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
362 cx.notify();
363 }
364
365 fn can_select(
366 &mut self,
367 ix: usize,
368 _window: &mut Window,
369 _cx: &mut Context<Picker<Self>>,
370 ) -> bool {
371 match self.filtered_entries.get(ix) {
372 Some(LanguageModelPickerEntry::Model(_)) => true,
373 Some(LanguageModelPickerEntry::Separator(_)) | None => false,
374 }
375 }
376
377 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
378 "Select a model…".into()
379 }
380
381 fn update_matches(
382 &mut self,
383 query: String,
384 window: &mut Window,
385 cx: &mut Context<Picker<Self>>,
386 ) -> Task<()> {
387 let all_models = self.all_models.clone();
388 let active_model = (self.get_active_model)(cx);
389 let bg_executor = cx.background_executor();
390
391 let language_model_registry = LanguageModelRegistry::global(cx);
392
393 let configured_providers = language_model_registry
394 .read(cx)
395 .providers()
396 .into_iter()
397 .filter(|provider| provider.is_authenticated(cx))
398 .collect::<Vec<_>>();
399
400 let configured_provider_ids = configured_providers
401 .iter()
402 .map(|provider| provider.id())
403 .collect::<Vec<_>>();
404
405 let recommended_models = all_models
406 .recommended
407 .iter()
408 .filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
409 .cloned()
410 .collect::<Vec<_>>();
411
412 let available_models = all_models
413 .all
414 .values()
415 .flat_map(|models| models.iter())
416 .filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
417 .cloned()
418 .collect::<Vec<_>>();
419
420 let matcher_rec = ModelMatcher::new(recommended_models, bg_executor.clone());
421 let matcher_all = ModelMatcher::new(available_models, bg_executor.clone());
422
423 let recommended = matcher_rec.exact_search(&query);
424 let all = matcher_all.fuzzy_search(&query);
425
426 let filtered_models = GroupedModels::new(all, recommended);
427
428 cx.spawn_in(window, async move |this, cx| {
429 this.update_in(cx, |this, window, cx| {
430 this.delegate.filtered_entries = filtered_models.entries();
431 // Finds the currently selected model in the list
432 let new_index =
433 Self::get_active_model_index(&this.delegate.filtered_entries, active_model);
434 this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
435 cx.notify();
436 })
437 .ok();
438 })
439 }
440
441 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
442 if let Some(LanguageModelPickerEntry::Model(model_info)) =
443 self.filtered_entries.get(self.selected_index)
444 {
445 let model = model_info.model.clone();
446 (self.on_model_changed)(model.clone(), cx);
447
448 let current_index = self.selected_index;
449 self.set_selected_index(current_index, window, cx);
450
451 cx.emit(DismissEvent);
452 }
453 }
454
455 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
456 cx.emit(DismissEvent);
457 }
458
459 fn render_match(
460 &self,
461 ix: usize,
462 selected: bool,
463 _: &mut Window,
464 cx: &mut Context<Picker<Self>>,
465 ) -> Option<Self::ListItem> {
466 match self.filtered_entries.get(ix)? {
467 LanguageModelPickerEntry::Separator(title) => Some(
468 div()
469 .px_2()
470 .pb_1()
471 .when(ix > 1, |this| {
472 this.mt_1()
473 .pt_2()
474 .border_t_1()
475 .border_color(cx.theme().colors().border_variant)
476 })
477 .child(
478 Label::new(title)
479 .size(LabelSize::XSmall)
480 .color(Color::Muted),
481 )
482 .into_any_element(),
483 ),
484 LanguageModelPickerEntry::Model(model_info) => {
485 let active_model = (self.get_active_model)(cx);
486 let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
487 let active_model_id = active_model.map(|m| m.model.id());
488
489 let is_selected = Some(model_info.model.provider_id()) == active_provider_id
490 && Some(model_info.model.id()) == active_model_id;
491
492 let model_icon_color = if is_selected {
493 Color::Accent
494 } else {
495 Color::Muted
496 };
497
498 Some(
499 ListItem::new(ix)
500 .inset(true)
501 .spacing(ListItemSpacing::Sparse)
502 .toggle_state(selected)
503 .child(
504 h_flex()
505 .w_full()
506 .gap_1p5()
507 .child(
508 Icon::new(model_info.icon)
509 .color(model_icon_color)
510 .size(IconSize::Small),
511 )
512 .child(Label::new(model_info.model.name().0).truncate()),
513 )
514 .end_slot(div().pr_3().when(is_selected, |this| {
515 this.child(
516 Icon::new(IconName::Check)
517 .color(Color::Accent)
518 .size(IconSize::Small),
519 )
520 }))
521 .into_any_element(),
522 )
523 }
524 }
525 }
526
527 fn render_footer(
528 &self,
529 _window: &mut Window,
530 cx: &mut Context<Picker<Self>>,
531 ) -> Option<gpui::AnyElement> {
532 let focus_handle = self.focus_handle.clone();
533
534 if !self.popover_styles {
535 return None;
536 }
537
538 Some(
539 h_flex()
540 .w_full()
541 .p_1p5()
542 .border_t_1()
543 .border_color(cx.theme().colors().border_variant)
544 .child(
545 Button::new("configure", "Configure")
546 .full_width()
547 .style(ButtonStyle::Outlined)
548 .key_binding(
549 KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
550 .map(|kb| kb.size(rems_from_px(12.))),
551 )
552 .on_click(|_, window, cx| {
553 window.dispatch_action(OpenSettings.boxed_clone(), cx);
554 }),
555 )
556 .into_any(),
557 )
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use futures::{future::BoxFuture, stream::BoxStream};
565 use gpui::{AsyncApp, TestAppContext, http_client};
566 use language_model::{
567 LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
568 LanguageModelName, LanguageModelProviderId, LanguageModelProviderName,
569 LanguageModelRequest, LanguageModelToolChoice,
570 };
571 use ui::IconName;
572
573 #[derive(Clone)]
574 struct TestLanguageModel {
575 name: LanguageModelName,
576 id: LanguageModelId,
577 provider_id: LanguageModelProviderId,
578 provider_name: LanguageModelProviderName,
579 }
580
581 impl TestLanguageModel {
582 fn new(name: &str, provider: &str) -> Self {
583 Self {
584 name: LanguageModelName::from(name.to_string()),
585 id: LanguageModelId::from(name.to_string()),
586 provider_id: LanguageModelProviderId::from(provider.to_string()),
587 provider_name: LanguageModelProviderName::from(provider.to_string()),
588 }
589 }
590 }
591
592 impl LanguageModel for TestLanguageModel {
593 fn id(&self) -> LanguageModelId {
594 self.id.clone()
595 }
596
597 fn name(&self) -> LanguageModelName {
598 self.name.clone()
599 }
600
601 fn provider_id(&self) -> LanguageModelProviderId {
602 self.provider_id.clone()
603 }
604
605 fn provider_name(&self) -> LanguageModelProviderName {
606 self.provider_name.clone()
607 }
608
609 fn supports_tools(&self) -> bool {
610 false
611 }
612
613 fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
614 false
615 }
616
617 fn supports_images(&self) -> bool {
618 false
619 }
620
621 fn telemetry_id(&self) -> String {
622 format!("{}/{}", self.provider_id.0, self.name.0)
623 }
624
625 fn max_token_count(&self) -> u64 {
626 1000
627 }
628
629 fn count_tokens(
630 &self,
631 _: LanguageModelRequest,
632 _: &App,
633 ) -> BoxFuture<'static, http_client::Result<u64>> {
634 unimplemented!()
635 }
636
637 fn stream_completion(
638 &self,
639 _: LanguageModelRequest,
640 _: &AsyncApp,
641 ) -> BoxFuture<
642 'static,
643 Result<
644 BoxStream<
645 'static,
646 Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
647 >,
648 LanguageModelCompletionError,
649 >,
650 > {
651 unimplemented!()
652 }
653 }
654
655 fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
656 model_specs
657 .into_iter()
658 .map(|(provider, name)| ModelInfo {
659 model: Arc::new(TestLanguageModel::new(name, provider)),
660 icon: IconName::Ai,
661 })
662 .collect()
663 }
664
665 fn assert_models_eq(result: Vec<ModelInfo>, expected: Vec<&str>) {
666 assert_eq!(
667 result.len(),
668 expected.len(),
669 "Number of models doesn't match"
670 );
671
672 for (i, expected_name) in expected.iter().enumerate() {
673 assert_eq!(
674 result[i].model.telemetry_id(),
675 *expected_name,
676 "Model at position {} doesn't match expected model",
677 i
678 );
679 }
680 }
681
682 #[gpui::test]
683 fn test_exact_match(cx: &mut TestAppContext) {
684 let models = create_models(vec![
685 ("zed", "Claude 3.7 Sonnet"),
686 ("zed", "Claude 3.7 Sonnet Thinking"),
687 ("zed", "gpt-4.1"),
688 ("zed", "gpt-4.1-nano"),
689 ("openai", "gpt-3.5-turbo"),
690 ("openai", "gpt-4.1"),
691 ("openai", "gpt-4.1-nano"),
692 ("ollama", "mistral"),
693 ("ollama", "deepseek"),
694 ]);
695 let matcher = ModelMatcher::new(models, cx.background_executor.clone());
696
697 // The order of models should be maintained, case doesn't matter
698 let results = matcher.exact_search("GPT-4.1");
699 assert_models_eq(
700 results,
701 vec![
702 "zed/gpt-4.1",
703 "zed/gpt-4.1-nano",
704 "openai/gpt-4.1",
705 "openai/gpt-4.1-nano",
706 ],
707 );
708 }
709
710 #[gpui::test]
711 fn test_fuzzy_match(cx: &mut TestAppContext) {
712 let models = create_models(vec![
713 ("zed", "Claude 3.7 Sonnet"),
714 ("zed", "Claude 3.7 Sonnet Thinking"),
715 ("zed", "gpt-4.1"),
716 ("zed", "gpt-4.1-nano"),
717 ("openai", "gpt-3.5-turbo"),
718 ("openai", "gpt-4.1"),
719 ("openai", "gpt-4.1-nano"),
720 ("ollama", "mistral"),
721 ("ollama", "deepseek"),
722 ]);
723 let matcher = ModelMatcher::new(models, cx.background_executor.clone());
724
725 // Results should preserve models order whenever possible.
726 // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
727 // similarity scores, but `zed/gpt-4.1` was higher in the models list,
728 // so it should appear first in the results.
729 let results = matcher.fuzzy_search("41");
730 assert_models_eq(
731 results,
732 vec![
733 "zed/gpt-4.1",
734 "openai/gpt-4.1",
735 "zed/gpt-4.1-nano",
736 "openai/gpt-4.1-nano",
737 ],
738 );
739
740 // Model provider should be searchable as well
741 let results = matcher.fuzzy_search("ol"); // meaning "ollama"
742 assert_models_eq(results, vec!["ollama/mistral", "ollama/deepseek"]);
743
744 // Fuzzy search
745 let results = matcher.fuzzy_search("z4n");
746 assert_models_eq(results, vec!["zed/gpt-4.1-nano"]);
747 }
748
749 #[gpui::test]
750 fn test_recommended_models_also_appear_in_other(_cx: &mut TestAppContext) {
751 let recommended_models = create_models(vec![("zed", "claude")]);
752 let all_models = create_models(vec![
753 ("zed", "claude"), // Should also appear in "other"
754 ("zed", "gemini"),
755 ("copilot", "o3"),
756 ]);
757
758 let grouped_models = GroupedModels::new(all_models, recommended_models);
759
760 let actual_all_models = grouped_models
761 .all
762 .values()
763 .flatten()
764 .cloned()
765 .collect::<Vec<_>>();
766
767 // Recommended models should also appear in "all"
768 assert_models_eq(
769 actual_all_models,
770 vec!["zed/claude", "zed/gemini", "copilot/o3"],
771 );
772 }
773
774 #[gpui::test]
775 fn test_models_from_different_providers(_cx: &mut TestAppContext) {
776 let recommended_models = create_models(vec![("zed", "claude")]);
777 let all_models = create_models(vec![
778 ("zed", "claude"), // Should also appear in "other"
779 ("zed", "gemini"),
780 ("copilot", "claude"), // Different provider, should appear in "other"
781 ]);
782
783 let grouped_models = GroupedModels::new(all_models, recommended_models);
784
785 let actual_all_models = grouped_models
786 .all
787 .values()
788 .flatten()
789 .cloned()
790 .collect::<Vec<_>>();
791
792 // All models should appear in "all" regardless of recommended status
793 assert_models_eq(
794 actual_all_models,
795 vec!["zed/claude", "zed/gemini", "copilot/claude"],
796 );
797 }
798}