1use std::{
2 sync::{Arc, Mutex},
3 time::Duration,
4};
5
6use async_trait::async_trait;
7use color_eyre::Result;
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
9use enum_cycling::EnumCycle;
10use parking_lot::RwLock;
11use ratatui::{
12 Frame,
13 layout::{Constraint, Layout, Rect},
14};
15use tokio_util::sync::CancellationToken;
16use tracing::instrument;
17use tui_textarea::CursorMove;
18
19use super::Component;
20use crate::{
21 app::Action,
22 component::{
23 edit::{EditCommandComponent, EditCommandComponentMode},
24 variable::VariableReplacementComponent,
25 },
26 config::{Config, KeyBindingsConfig, SearchConfig, Theme},
27 errors::AppError,
28 format_msg,
29 model::{Command, CommandTemplate, SOURCE_WORKSPACE, SearchMode},
30 process::ProcessOutput,
31 service::IntelliShellService,
32 widgets::{CustomList, CustomTextArea, ErrorPopup, NewVersionBanner, items::string::CommentString},
33};
34
35const EMPTY_STORAGE_MESSAGE: &str = r#"There are no stored commands yet!
36 - Try to bookmark some command with 'Ctrl + B'
37 - Or execute 'intelli-shell tldr fetch' to download a bunch of tldr's useful commands"#;
38
39#[derive(Clone)]
41pub struct SearchCommandsComponent {
42 theme: Theme,
44 inline: bool,
46 exec_on_alias_match: bool,
48 service: IntelliShellService,
50 layout: Layout,
52 search_delay: Duration,
54 refresh_token: Arc<Mutex<Option<CancellationToken>>>,
56 global_cancellation_token: CancellationToken,
58 state: Arc<RwLock<SearchCommandsComponentState<'static>>>,
60}
61struct SearchCommandsComponentState<'a> {
62 initialize_with_ai: bool,
64 mode: SearchMode,
66 user_only: bool,
68 query: CustomTextArea<'a>,
70 ai_mode: bool,
72 tags: Option<CustomList<'a, CommentString>>,
74 alias_match: bool,
76 commands: CustomList<'a, Command>,
78 error: ErrorPopup<'a>,
80}
81
82impl SearchCommandsComponent {
83 pub fn new(
85 service: IntelliShellService,
86 config: Config,
87 inline: bool,
88 query: impl Into<String>,
89 initialize_with_ai: bool,
90 cancellation_token: CancellationToken,
91 ) -> Self {
92 let query = CustomTextArea::new(config.theme.primary, inline, false, query.into()).focused();
93
94 let commands = CustomList::new(config.theme.clone(), inline, Vec::new());
95
96 let error = ErrorPopup::empty(&config.theme);
97
98 let layout = if inline {
99 Layout::vertical([Constraint::Length(1), Constraint::Min(3)])
100 } else {
101 Layout::vertical([Constraint::Length(3), Constraint::Min(5)]).margin(1)
102 };
103
104 let SearchConfig {
105 delay,
106 mode,
107 user_only,
108 exec_on_alias_match,
109 } = config.search;
110
111 let ret = Self {
112 theme: config.theme,
113 inline,
114 exec_on_alias_match,
115 service,
116 layout,
117 search_delay: Duration::from_millis(delay),
118 refresh_token: Arc::new(Mutex::new(None)),
119 global_cancellation_token: cancellation_token,
120 state: Arc::new(RwLock::new(SearchCommandsComponentState {
121 initialize_with_ai,
122 mode,
123 user_only,
124 query,
125 ai_mode: false,
126 tags: None,
127 alias_match: false,
128 commands,
129 error,
130 })),
131 };
132
133 ret.update_config(None, None, None);
134
135 ret
136 }
137
138 fn update_config(&self, search_mode: Option<SearchMode>, user_only: Option<bool>, ai_mode: Option<bool>) {
140 let mut state = self.state.write();
141 if let Some(search_mode) = search_mode {
142 state.mode = search_mode;
143 }
144 if let Some(user_only) = user_only {
145 state.user_only = user_only;
146 }
147 if let Some(ai_mode) = ai_mode {
148 state.ai_mode = ai_mode;
149 }
150
151 let search_mode = state.mode;
152 let title = match (state.ai_mode, self.inline, state.user_only) {
153 (true, true, _) => String::from("(ai)"),
154 (false, true, true) => format!("({search_mode},user)"),
155 (false, true, false) => format!("({search_mode})"),
156 (true, false, _) => String::from(" Query (ai) "),
157 (false, false, true) => format!(" Query ({search_mode},user) "),
158 (false, false, false) => format!(" Query ({search_mode}) "),
159 };
160
161 state.query.set_title(title);
162 }
163}
164
165#[async_trait]
166impl Component for SearchCommandsComponent {
167 fn name(&self) -> &'static str {
168 "SearchCommandsComponent"
169 }
170
171 fn min_inline_height(&self) -> u16 {
172 1 + 10
174 }
175
176 #[instrument(skip_all)]
177 async fn init_and_peek(&mut self) -> Result<Action> {
178 let initialize_with_ai = self.state.read().initialize_with_ai;
180 if initialize_with_ai {
181 let res = self.prompt_ai().await;
182 self.state.write().initialize_with_ai = false;
183 return res;
184 }
185 if self.service.is_storage_empty().await.map_err(AppError::into_report)? {
187 Ok(Action::Quit(
188 ProcessOutput::success().stderr(format_msg!(self.theme, "{EMPTY_STORAGE_MESSAGE}")),
189 ))
190 } else {
191 let tags = {
193 let state = self.state.read();
194 state.query.lines_as_string() == "#"
195 };
196 if tags {
197 self.refresh_tags().await?;
198 } else {
199 self.refresh_commands().await?;
200 let command = {
202 let state = self.state.read();
203 if state.alias_match && state.commands.len() == 1 {
204 state.commands.selected().cloned()
205 } else {
206 None
207 }
208 };
209 if let Some(command) = command {
210 tracing::info!("Found a single alias command: {}", command.cmd);
211 return self.confirm_command(command, self.exec_on_alias_match, false).await;
212 }
213 }
214 Ok(Action::NoOp)
215 }
216 }
217
218 #[instrument(skip_all)]
219 fn render(&mut self, frame: &mut Frame, area: Rect) {
220 let [query_area, suggestions_area] = self.layout.areas(area);
222
223 let mut state = self.state.write();
224
225 frame.render_widget(&state.query, query_area);
227
228 if let Some(ref mut tags) = state.tags {
230 frame.render_widget(tags, suggestions_area);
231 } else {
232 frame.render_widget(&mut state.commands, suggestions_area);
233 }
234
235 if let Some(new_version) = self.service.poll_new_version() {
237 NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
238 }
239 state.error.render_in(frame, area);
240 }
241
242 fn tick(&mut self) -> Result<Action> {
243 let mut state = self.state.write();
244 state.query.tick();
245 state.error.tick();
246 Ok(Action::NoOp)
247 }
248
249 fn exit(&mut self) -> Result<Action> {
250 let (ai_mode, tags) = {
251 let state = self.state.read();
252 (state.ai_mode, state.tags.is_some())
253 };
254 if ai_mode {
255 tracing::debug!("Closing ai mode: user request");
256 self.update_config(None, None, Some(false));
257 self.schedule_debounced_command_refresh();
258 Ok(Action::NoOp)
259 } else if tags {
260 tracing::debug!("Closing tag mode: user request");
261 let mut state = self.state.write();
262 state.tags = None;
263 state.commands.set_focus(true);
264 self.schedule_debounced_command_refresh();
265 Ok(Action::NoOp)
266 } else {
267 tracing::info!("User requested to exit");
268 let state = self.state.read();
269 let query = state.query.lines_as_string();
270 Ok(Action::Quit(if query.trim().is_empty() {
271 ProcessOutput::success()
272 } else {
273 ProcessOutput::success().fileout(query)
274 }))
275 }
276 }
277
278 async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
279 if key.code == KeyCode::Char(' ') && key.modifiers == KeyModifiers::CONTROL {
281 self.debounced_refresh_tags();
282 Ok(Action::NoOp)
283 } else {
284 Ok(self
286 .default_process_key_event(keybindings, key)
287 .await?
288 .unwrap_or_default())
289 }
290 }
291
292 fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
293 match mouse.kind {
294 MouseEventKind::ScrollDown => Ok(self.move_down()?),
295 MouseEventKind::ScrollUp => Ok(self.move_up()?),
296 _ => Ok(Action::NoOp),
297 }
298 }
299
300 fn move_up(&mut self) -> Result<Action> {
301 let mut state = self.state.write();
302 if !state.query.is_ai_loading() {
303 if let Some(ref mut tags) = state.tags {
304 tags.select_prev();
305 } else {
306 state.commands.select_prev();
307 }
308 }
309 Ok(Action::NoOp)
310 }
311
312 fn move_down(&mut self) -> Result<Action> {
313 let mut state = self.state.write();
314 if !state.query.is_ai_loading() {
315 if let Some(ref mut tags) = state.tags {
316 tags.select_next();
317 } else {
318 state.commands.select_next();
319 }
320 }
321 Ok(Action::NoOp)
322 }
323
324 fn move_left(&mut self, word: bool) -> Result<Action> {
325 let mut state = self.state.write();
326 if state.tags.is_none() {
327 state.query.move_cursor_left(word);
328 }
329 Ok(Action::NoOp)
330 }
331
332 fn move_right(&mut self, word: bool) -> Result<Action> {
333 let mut state = self.state.write();
334 if state.tags.is_none() {
335 state.query.move_cursor_right(word);
336 }
337 Ok(Action::NoOp)
338 }
339
340 fn move_prev(&mut self) -> Result<Action> {
341 self.move_up()
342 }
343
344 fn move_next(&mut self) -> Result<Action> {
345 self.move_down()
346 }
347
348 fn move_home(&mut self, absolute: bool) -> Result<Action> {
349 let mut state = self.state.write();
350 if !state.query.is_ai_loading() {
351 if let Some(ref mut tags) = state.tags {
352 tags.select_first();
353 } else if absolute {
354 state.commands.select_first();
355 } else {
356 state.query.move_home(false);
357 }
358 }
359 Ok(Action::NoOp)
360 }
361
362 fn move_end(&mut self, absolute: bool) -> Result<Action> {
363 let mut state = self.state.write();
364 if !state.query.is_ai_loading() {
365 if let Some(ref mut tags) = state.tags {
366 tags.select_last();
367 } else if absolute {
368 state.commands.select_last();
369 } else {
370 state.query.move_end(false);
371 }
372 }
373 Ok(Action::NoOp)
374 }
375
376 fn undo(&mut self) -> Result<Action> {
377 let mut state = self.state.write();
378 if !state.query.is_ai_loading() {
379 state.query.undo();
380 if state.tags.is_some() {
381 self.debounced_refresh_tags();
382 } else {
383 self.schedule_debounced_command_refresh();
384 }
385 }
386 Ok(Action::NoOp)
387 }
388
389 fn redo(&mut self) -> Result<Action> {
390 let mut state = self.state.write();
391 if !state.query.is_ai_loading() {
392 state.query.redo();
393 if state.tags.is_some() {
394 self.debounced_refresh_tags();
395 } else {
396 self.schedule_debounced_command_refresh();
397 }
398 }
399 Ok(Action::NoOp)
400 }
401
402 fn insert_text(&mut self, text: String) -> Result<Action> {
403 let mut state = self.state.write();
404 state.query.insert_str(text);
405 if state.tags.is_some() {
406 self.debounced_refresh_tags();
407 } else {
408 self.schedule_debounced_command_refresh();
409 }
410 Ok(Action::NoOp)
411 }
412
413 fn insert_char(&mut self, c: char) -> Result<Action> {
414 let mut state = self.state.write();
415 state.query.insert_char(c);
416 if c == '#' || state.tags.is_some() {
417 self.debounced_refresh_tags();
418 } else {
419 self.schedule_debounced_command_refresh();
420 }
421 Ok(Action::NoOp)
422 }
423
424 fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
425 let mut state = self.state.write();
426 state.query.delete(backspace, word);
427 if state.tags.is_some() {
428 self.debounced_refresh_tags();
429 } else {
430 self.schedule_debounced_command_refresh();
431 }
432 Ok(Action::NoOp)
433 }
434
435 fn toggle_search_mode(&mut self) -> Result<Action> {
436 let (search_mode, ai_mode, tags) = {
437 let state = self.state.read();
438 if state.query.is_ai_loading() {
439 return Ok(Action::NoOp);
440 }
441 (state.mode, state.ai_mode, state.tags.is_some())
442 };
443 if ai_mode {
444 tracing::debug!("Closing ai mode: user toggled search mode");
445 self.update_config(None, None, Some(false));
446 } else {
447 self.update_config(Some(search_mode.down()), None, None);
448 }
449 if tags {
450 self.debounced_refresh_tags();
451 } else {
452 self.schedule_debounced_command_refresh();
453 }
454 Ok(Action::NoOp)
455 }
456
457 fn toggle_search_user_only(&mut self) -> Result<Action> {
458 let (user_only, ai_mode, tags) = {
459 let state = self.state.read();
460 (state.user_only, state.ai_mode, state.tags.is_some())
461 };
462 if !ai_mode {
463 self.update_config(None, Some(!user_only), None);
464 if tags {
465 self.debounced_refresh_tags();
466 } else {
467 self.schedule_debounced_command_refresh();
468 }
469 }
470 Ok(Action::NoOp)
471 }
472
473 #[instrument(skip_all)]
474 async fn selection_delete(&mut self) -> Result<Action> {
475 let command = {
476 let mut state = self.state.write();
477 if !state.ai_mode
478 && let Some(selected) = state.commands.selected()
479 {
480 if selected.source != SOURCE_WORKSPACE {
481 state.commands.delete_selected()
482 } else {
483 state.error.set_temp_message("Workspace commands can't be deleted");
484 return Ok(Action::NoOp);
485 }
486 } else {
487 None
488 }
489 };
490
491 if let Some((_, command)) = command {
492 self.service
493 .delete_command(command.id)
494 .await
495 .map_err(AppError::into_report)?;
496 }
497
498 Ok(Action::NoOp)
499 }
500
501 #[instrument(skip_all)]
502 async fn selection_update(&mut self) -> Result<Action> {
503 let command = {
504 let state = self.state.read();
505 if state.ai_mode {
506 return Ok(Action::NoOp);
507 }
508 state.commands.selected().cloned()
509 };
510 if let Some(command) = command {
511 if command.source != SOURCE_WORKSPACE {
512 tracing::info!("Entering command update for: {}", command.cmd);
513 Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
514 self.service.clone(),
515 self.theme.clone(),
516 self.inline,
517 command,
518 EditCommandComponentMode::Edit {
519 parent: Box::new(self.clone()),
520 },
521 self.global_cancellation_token.clone(),
522 ))))
523 } else {
524 self.state
525 .write()
526 .error
527 .set_temp_message("Workspace commands can't be updated");
528 Ok(Action::NoOp)
529 }
530 } else {
531 Ok(Action::NoOp)
532 }
533 }
534
535 #[instrument(skip_all)]
536 async fn selection_confirm(&mut self) -> Result<Action> {
537 let (selected_tag, cursor_pos, query, command, ai_mode) = {
538 let state = self.state.read();
539 if state.query.is_ai_loading() {
540 return Ok(Action::NoOp);
541 }
542 let selected_tag = state.tags.as_ref().and_then(|s| s.selected().cloned());
543 (
544 selected_tag.map(String::from),
545 state.query.cursor().1,
546 state.query.lines_as_string(),
547 state.commands.selected().cloned(),
548 state.ai_mode,
549 )
550 };
551
552 if let Some(tag) = selected_tag {
553 tracing::debug!("Selected tag: {tag}");
554 self.confirm_tag(tag, query, cursor_pos).await
555 } else if let Some(command) = command {
556 tracing::info!("Selected command: {}", command.cmd);
557 self.confirm_command(command, false, ai_mode).await
558 } else {
559 Ok(Action::NoOp)
560 }
561 }
562
563 #[instrument(skip_all)]
564 async fn selection_execute(&mut self) -> Result<Action> {
565 let (command, ai_mode) = {
566 let state = self.state.read();
567 if state.query.is_ai_loading() {
568 return Ok(Action::NoOp);
569 }
570 (state.commands.selected().cloned(), state.ai_mode)
571 };
572 if let Some(command) = command {
573 tracing::info!("Selected command to execute: {}", command.cmd);
574 self.confirm_command(command, true, ai_mode).await
575 } else {
576 Ok(Action::NoOp)
577 }
578 }
579
580 async fn prompt_ai(&mut self) -> Result<Action> {
581 let mut state = self.state.write();
582 if state.tags.is_some() || state.query.is_ai_loading() {
583 return Ok(Action::NoOp);
584 }
585 let query = state.query.lines_as_string();
586 if !query.is_empty() {
587 state.query.set_ai_loading(true);
588 drop(state);
589 self.update_config(None, None, Some(true));
590 let this = self.clone();
591 tokio::spawn(async move {
592 let res = this
593 .service
594 .suggest_commands(&query, this.global_cancellation_token.clone())
595 .await;
596 let mut state = this.state.write();
597 let commands = match res {
598 Ok(suggestions) => {
599 if !suggestions.is_empty() {
600 state.error.clear_message();
601 state.alias_match = false;
602 suggestions
603 } else {
604 state
605 .error
606 .set_temp_message("AI did not return any suggestion".to_string());
607 Vec::new()
608 }
609 }
610 Err(AppError::UserFacing(err)) => {
611 tracing::warn!("{err}");
612 state.error.set_temp_message(err.to_string());
613 Vec::new()
614 }
615 Err(AppError::Unexpected(err)) => panic!("Error prompting for command suggestions: {err:?}"),
616 };
617 state.commands.update_items(commands, true);
618 state.query.set_ai_loading(false);
619 });
620 }
621 Ok(Action::NoOp)
622 }
623}
624
625impl SearchCommandsComponent {
626 fn schedule_debounced_command_refresh(&self) {
628 let cancellation_token = {
629 let mut token_guard = self.refresh_token.lock().unwrap();
631 if let Some(token) = token_guard.take() {
632 token.cancel();
633 }
634 let new_token = CancellationToken::new();
636 *token_guard = Some(new_token.clone());
637 new_token
638 };
639
640 let this = self.clone();
642 tokio::spawn(async move {
643 tokio::select! {
644 biased;
645 _ = cancellation_token.cancelled() => {}
647 _ = tokio::time::sleep(this.search_delay) => {
649 if let Err(err) = this.refresh_commands().await {
650 panic!("Error refreshing commands: {err:?}");
651 }
652 }
653 }
654 });
655 }
656
657 #[instrument(skip_all)]
659 async fn refresh_commands(&self) -> Result<()> {
660 let (mode, user_only, ai_mode, query) = {
662 let state = self.state.read();
663 (
664 state.mode,
665 state.user_only,
666 state.ai_mode,
667 state.query.lines_as_string(),
668 )
669 };
670
671 if ai_mode {
673 return Ok(());
674 }
675
676 let res = self.service.search_commands(mode, user_only, &query).await;
678
679 let mut state = self.state.write();
681 let commands = match res {
682 Ok((commands, alias_match)) => {
683 state.error.clear_message();
684 state.alias_match = alias_match;
685 commands
686 }
687 Err(AppError::UserFacing(err)) => {
688 tracing::warn!("{err}");
689 state.error.set_perm_message(err.to_string());
690 Vec::new()
691 }
692 Err(AppError::Unexpected(err)) => return Err(err),
693 };
694 state.commands.update_items(commands, true);
695
696 Ok(())
697 }
698
699 fn debounced_refresh_tags(&self) {
701 let this = self.clone();
702 tokio::spawn(async move {
703 if let Err(err) = this.refresh_tags().await {
704 panic!("Error refreshing tags: {err:?}");
705 }
706 });
707 }
708
709 #[instrument(skip_all)]
711 async fn refresh_tags(&self) -> Result<()> {
712 let (mode, user_only, ai_mode, query, cursor_pos) = {
714 let state = self.state.read();
715 (
716 state.mode,
717 state.user_only,
718 state.ai_mode,
719 state.query.lines_as_string(),
720 state.query.cursor().1,
721 )
722 };
723
724 if ai_mode {
726 return Ok(());
727 }
728
729 let res = self.service.search_tags(mode, user_only, &query, cursor_pos).await;
731
732 let mut state = self.state.write();
734 match res {
735 Ok(None) => {
736 tracing::trace!("No editing tags");
737 if state.tags.is_some() {
738 tracing::debug!("Closing tag mode: no editing tag");
739 state.tags = None;
740 state.commands.set_focus(true);
741 }
742 self.schedule_debounced_command_refresh();
743 Ok(())
744 }
745 Ok(Some(tags)) if tags.is_empty() => {
746 tracing::trace!("No tags found");
747 if state.tags.is_some() {
748 tracing::debug!("Closing tag mode: no tags found");
749 state.tags = None;
750 state.commands.set_focus(true);
751 }
752 self.schedule_debounced_command_refresh();
753 Ok(())
754 }
755 Ok(Some(tags)) => {
756 state.error.clear_message();
757 if tags.len() == 1 && tags.iter().all(|(_, _, exact_match)| *exact_match) {
758 tracing::trace!("Exact tag found only");
759 if state.tags.is_some() {
760 tracing::debug!("Closing tag mode: exact tag found");
761 state.tags = None;
762 state.commands.set_focus(true);
763 }
764 self.schedule_debounced_command_refresh();
765 } else {
766 tracing::trace!("Found {} tags", tags.len());
767 let tags = tags.into_iter().map(|(tag, _, _)| CommentString::from(tag)).collect();
768 let tags_list = if let Some(ref mut list) = state.tags {
769 list
770 } else {
771 tracing::debug!("Entering tag mode");
772 state
773 .tags
774 .insert(CustomList::new(self.theme.clone(), self.inline, Vec::new()))
775 };
776 tags_list.update_items(tags, true);
777 state.commands.set_focus(false);
778 }
779
780 Ok(())
781 }
782 Err(AppError::UserFacing(err)) => {
783 tracing::warn!("{err}");
784 state.error.set_perm_message(err.to_string());
785 if state.tags.is_some() {
786 tracing::debug!("Closing tag mode");
787 state.tags = None;
788 state.commands.set_focus(true);
789 }
790 Ok(())
791 }
792 Err(AppError::Unexpected(err)) => Err(err),
793 }
794 }
795
796 #[instrument(skip_all)]
798 async fn confirm_tag(&mut self, tag: String, query: String, cursor_pos: usize) -> Result<Action> {
799 let mut tag_start = cursor_pos.wrapping_sub(1);
801 let chars: Vec<_> = query.chars().collect();
802 while tag_start > 0 && chars[tag_start] != '#' {
803 tag_start -= 1;
804 }
805 let mut tag_end = cursor_pos;
806 while tag_end < chars.len() && chars[tag_end] != ' ' {
807 tag_end += 1;
808 }
809 let mut state = self.state.write();
810 if chars[tag_start] == '#' {
811 state.query.select_all();
813 state.query.cut();
814 state
815 .query
816 .insert_str(format!("{}{} {}", &query[..tag_start], tag, &query[tag_end..]));
817 state
818 .query
819 .move_cursor(CursorMove::Jump(0, (tag_start + tag.len() + 1) as u16));
820 }
821 state.tags = None;
822 state.commands.set_focus(true);
823 self.schedule_debounced_command_refresh();
824 Ok(Action::NoOp)
825 }
826
827 #[instrument(skip_all)]
830 async fn confirm_command(&mut self, command: Command, execute: bool, ai_command: bool) -> Result<Action> {
831 if !ai_command && command.source != SOURCE_WORKSPACE {
833 self.service
834 .increment_command_usage(command.id)
835 .await
836 .map_err(AppError::into_report)?;
837 }
838 let template = CommandTemplate::parse(&command.cmd, false);
840 if template.has_pending_variable() {
841 Ok(Action::SwitchComponent(Box::new(VariableReplacementComponent::new(
843 self.service.clone(),
844 self.theme.clone(),
845 self.inline,
846 execute,
847 false,
848 template,
849 self.global_cancellation_token.clone(),
850 ))))
851 } else if execute {
852 Ok(Action::Quit(ProcessOutput::execute(command.cmd)))
854 } else {
855 Ok(Action::Quit(
857 ProcessOutput::success().stdout(&command.cmd).fileout(command.cmd),
858 ))
859 }
860 }
861}